Add procedural world and ship progression foundations

This commit is contained in:
Admin 2026-09-20 08:31:54 +00:00
parent 96b042a4c2
commit ac07945e92
6 changed files with 678 additions and 0 deletions

125
src/progression/modules.js Normal file
View File

@ -0,0 +1,125 @@
export const SHIP_BASE_STATS = {
maxHull: 100,
maxSpeed: 180,
reverseSpeed: -55,
acceleration: 92,
brake: 118,
turnRate: 76,
reloadMs: 850,
cannonDamage: 16,
windExposure: 1,
cargoCapacity: 4,
salvageCapacity: 2,
radarRange: 900,
};
export const MODULE_CATALOG = {
'stock-engine': {
id: 'stock-engine',
name: 'Stock Twin Propellers',
slot: 'engine',
rarity: 'common',
price: 0,
modifiers: {},
},
'tuned-propellers': {
id: 'tuned-propellers',
name: 'Tuned Propellers',
slot: 'engine',
rarity: 'improved',
price: 280,
modifiers: { maxSpeed: 20, acceleration: 10 },
},
'corsair-overdrive': {
id: 'corsair-overdrive',
name: 'Corsair Overdrive',
slot: 'engine',
rarity: 'rare',
price: 690,
modifiers: { maxSpeed: 34, acceleration: 18, turnRate: -5 },
},
'standard-broadside': {
id: 'standard-broadside',
name: 'Standard Broadside',
slot: 'weapon',
rarity: 'common',
price: 0,
modifiers: {},
},
'long-nine-cannons': {
id: 'long-nine-cannons',
name: 'Long Nine Cannons',
slot: 'weapon',
rarity: 'improved',
price: 360,
modifiers: { cannonDamage: 4, reloadMs: 70 },
},
'quicklock-breeches': {
id: 'quicklock-breeches',
name: 'Quicklock Breeches',
slot: 'weapon',
rarity: 'rare',
price: 620,
modifiers: { cannonDamage: 2, reloadMs: -180 },
},
'timber-hull': {
id: 'timber-hull',
name: 'Timber Hull',
slot: 'hull',
rarity: 'common',
price: 0,
modifiers: {},
},
'reinforced-keel': {
id: 'reinforced-keel',
name: 'Reinforced Keel',
slot: 'hull',
rarity: 'improved',
price: 420,
modifiers: { maxHull: 28, maxSpeed: -7 },
},
'stormglass-nav': {
id: 'stormglass-nav',
name: 'Stormglass Navigator',
slot: 'navigation',
rarity: 'rare',
price: 560,
modifiers: { windExposure: -0.22, radarRange: 240 },
},
'brass-compass': {
id: 'brass-compass',
name: 'Brass Compass',
slot: 'navigation',
rarity: 'common',
price: 0,
modifiers: {},
},
'salvage-rig': {
id: 'salvage-rig',
name: 'Deck Salvage Rig',
slot: 'utility',
rarity: 'improved',
price: 330,
modifiers: { salvageCapacity: 2 },
},
'expanded-hold': {
id: 'expanded-hold',
name: 'Expanded Cargo Hold',
slot: 'cargo',
rarity: 'improved',
price: 350,
modifiers: { cargoCapacity: 4, maxSpeed: -4 },
},
};
export const STARTER_LOADOUT = {
engine: 'stock-engine',
weapon: 'standard-broadside',
hull: 'timber-hull',
navigation: 'brass-compass',
utility: null,
cargo: null,
};
export const purchasableModules = () => Object.values(MODULE_CATALOG)
.filter((module) => module.price > 0);

View File

@ -0,0 +1,153 @@
import {
MODULE_CATALOG,
SHIP_BASE_STATS,
STARTER_LOADOUT,
purchasableModules,
} from './modules.js';
import { createSeededRandom } from '../world/seeded-random.js';
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
export const createStarterProgression = () => ({
credits: 650,
scrap: 0,
loadout: { ...STARTER_LOADOUT },
inventory: [],
});
export const deriveShipStats = (loadout = STARTER_LOADOUT) => {
const stats = { ...SHIP_BASE_STATS };
for (const moduleId of Object.values(loadout)) {
if (!moduleId) continue;
const module = MODULE_CATALOG[moduleId];
if (!module) continue;
for (const [key, value] of Object.entries(module.modifiers || {})) {
stats[key] = (stats[key] ?? 0) + value;
}
}
stats.maxHull = Math.max(1, stats.maxHull);
stats.maxSpeed = Math.max(40, stats.maxSpeed);
stats.acceleration = Math.max(10, stats.acceleration);
stats.turnRate = Math.max(15, stats.turnRate);
stats.reloadMs = Math.max(260, stats.reloadMs);
stats.cannonDamage = Math.max(1, stats.cannonDamage);
stats.windExposure = clamp(stats.windExposure, 0.35, 1.5);
stats.cargoCapacity = Math.max(1, Math.floor(stats.cargoCapacity));
stats.salvageCapacity = Math.max(1, Math.floor(stats.salvageCapacity));
return stats;
};
export const installModule = (progression, moduleId) => {
const module = MODULE_CATALOG[moduleId];
if (!module) return { ...progression, installed: false, reason: 'unknown-module' };
const inventory = [...(progression.inventory || [])];
const inventoryIndex = inventory.indexOf(moduleId);
if (inventoryIndex >= 0) inventory.splice(inventoryIndex, 1);
const loadout = { ...(progression.loadout || STARTER_LOADOUT) };
const replaced = loadout[module.slot] || null;
loadout[module.slot] = moduleId;
if (replaced && MODULE_CATALOG[replaced]?.price > 0) inventory.push(replaced);
return {
...progression,
loadout,
inventory,
installed: true,
replaced,
installedModule: moduleId,
};
};
export const buyAndInstallModule = (progression, moduleId) => {
const module = MODULE_CATALOG[moduleId];
if (!module || module.price <= 0) return { ...progression, purchased: false, reason: 'not-for-sale' };
if ((progression.credits || 0) < module.price) {
return { ...progression, purchased: false, reason: 'insufficient-credits' };
}
const purchased = {
...progression,
credits: progression.credits - module.price,
inventory: [...(progression.inventory || []), moduleId],
};
return { ...installModule(purchased, moduleId), purchased: true };
};
export const marketForPort = (port) => {
const stock = purchasableModules().filter((module) => {
if (port?.type === 'industrial') return ['engine', 'hull', 'utility', 'cargo'].includes(module.slot);
if (port?.type === 'military') return ['weapon', 'hull', 'navigation'].includes(module.slot);
if (port?.type === 'fuel') return ['engine', 'navigation', 'cargo'].includes(module.slot);
return true;
});
return stock.sort((a, b) => a.price - b.price);
};
export const salvageForPirate = ({
seed = 'salvage',
classKey = 'raider',
wave = 1,
sequence = 0,
} = {}) => {
const random = createSeededRandom(`${seed}:${classKey}:${wave}:${sequence}`);
const chance = classKey === 'captain' ? 0.86 : classKey === 'frigate' ? 0.62 : 0.42;
const modulePool = purchasableModules();
const module = random() < chance
? modulePool[Math.floor(random() * modulePool.length)]
: null;
const scrap = 1 + Math.floor(random() * (classKey === 'captain' ? 8 : classKey === 'frigate' ? 5 : 3));
const credits = 20 + Math.floor(random() * (35 + wave * 8));
return {
moduleId: module?.id || null,
scrap,
credits,
condition: module ? clamp(0.28 + random() * 0.67, 0.28, 0.95) : null,
};
};
export const collectSalvage = (progression, salvage, stats = deriveShipStats(progression.loadout)) => {
const inventory = [...(progression.inventory || [])];
let moduleCollected = false;
if (salvage?.moduleId && inventory.length < stats.salvageCapacity) {
inventory.push(salvage.moduleId);
moduleCollected = true;
}
return {
...progression,
inventory,
scrap: (progression.scrap || 0) + Math.max(0, salvage?.scrap || 0),
credits: (progression.credits || 0) + Math.max(0, salvage?.credits || 0),
lastSalvage: { ...salvage, moduleCollected },
};
};
export const bestAffordableMarketUpgrade = (progression, port) => {
const stats = deriveShipStats(progression.loadout);
const candidates = marketForPort(port)
.filter((module) => module.price <= (progression.credits || 0))
.filter((module) => progression.loadout?.[module.slot] !== module.id)
.map((module) => {
const next = deriveShipStats({ ...progression.loadout, [module.slot]: module.id });
const score = (next.maxHull - stats.maxHull) * 0.7
+ (next.maxSpeed - stats.maxSpeed)
+ (next.acceleration - stats.acceleration) * 0.45
+ (next.turnRate - stats.turnRate) * 0.35
+ (stats.reloadMs - next.reloadMs) * 0.06
+ (next.cannonDamage - stats.cannonDamage) * 5
+ (stats.windExposure - next.windExposure) * 30
+ (next.salvageCapacity - stats.salvageCapacity) * 8
+ (next.cargoCapacity - stats.cargoCapacity) * 4;
return { module, score };
})
.sort((a, b) => b.score - a.score);
return candidates[0]?.module || null;
};

View File

@ -0,0 +1,22 @@
export const hashSeed = (seed = '') => {
let hash = 2166136261;
for (const char of String(seed)) {
hash ^= char.charCodeAt(0);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
};
export const createSeededRandom = (seed = '') => {
let state = hashSeed(seed) || 0x6d2b79f5;
return () => {
state += 0x6d2b79f5;
let value = state;
value = Math.imul(value ^ (value >>> 15), value | 1);
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
};
};
export const randomBetween = (random, min, max) => min + (max - min) * random();
export const randomInt = (random, min, max) => Math.floor(randomBetween(random, min, max + 1));

View File

@ -0,0 +1,262 @@
import { createSeededRandom, randomBetween, randomInt } from './seeded-random.js';
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
const distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
const pointSegmentDistance = (point, start, end) => {
const dx = end.x - start.x;
const dy = end.y - start.y;
const lengthSquared = dx * dx + dy * dy;
if (lengthSquared <= 0) return distance(point, start);
const t = clamp(((point.x - start.x) * dx + (point.y - start.y) * dy) / lengthSquared, 0, 1);
return Math.hypot(point.x - (start.x + dx * t), point.y - (start.y + dy * t));
};
const nearestPortDistance = (island, ports) => ports.length
? Math.min(...ports.map((port) => distance(island, port)))
: Infinity;
const createRegions = (random, width, height) => {
const types = ['trade', 'settled', 'frontier', 'pirate', 'weather', 'frontier'];
return types.map((type, index) => ({
id: `region-${index + 1}`,
type,
x: randomBetween(random, width * 0.15, width * 0.85),
y: randomBetween(random, height * 0.15, height * 0.85),
radius: randomBetween(random, width * 0.18, width * 0.32),
wealth: type === 'trade' ? randomBetween(random, 0.72, 0.96) : randomBetween(random, 0.25, 0.78),
security: type === 'settled' ? randomBetween(random, 0.72, 0.95)
: type === 'pirate' ? randomBetween(random, 0.05, 0.25)
: randomBetween(random, 0.28, 0.68),
weatherSeverity: type === 'weather' ? randomBetween(random, 0.72, 0.98) : randomBetween(random, 0.12, 0.7),
}));
};
const generateIslands = (random, width, height, count) => {
const islands = [];
const margin = Math.min(width, height) * 0.07;
const minSpacing = Math.min(width, height) * 0.085;
for (let attempt = 0; islands.length < count && attempt < count * 80; attempt += 1) {
const radius = randomBetween(random, 68, 148);
const candidate = {
id: `island-${islands.length + 1}`,
x: randomBetween(random, margin + radius, width - margin - radius),
y: randomBetween(random, margin + radius, height - margin - radius),
radius,
turbulenceRadius: radius * randomBetween(random, 2.2, 2.9),
settlementRoll: random(),
resource: ['timber', 'ore', 'food', 'fuel'][randomInt(random, 0, 3)],
};
if (islands.every((other) => distance(candidate, other) >= minSpacing + radius + other.radius * 0.35)) {
islands.push(candidate);
}
}
return islands;
};
const generatePorts = (random, islands, count) => {
const selected = [...islands]
.sort((a, b) => b.settlementRoll - a.settlementRoll)
.slice(0, Math.min(count, islands.length));
return selected.map((island, index) => {
const portType = index === 0 ? 'capital'
: island.resource === 'ore' ? 'industrial'
: island.resource === 'fuel' ? 'fuel'
: index % 3 === 0 ? 'military'
: 'trade';
const baseSecurity = portType === 'capital' ? 0.92
: portType === 'military' ? 0.82
: randomBetween(random, 0.42, 0.76);
return {
id: `port-${index + 1}`,
islandId: island.id,
name: index === 0 ? 'Aerie Harbour' : `Skyport ${index + 1}`,
x: island.x,
y: island.y,
type: portType,
wealth: portType === 'capital' ? 0.9 : randomBetween(random, 0.38, 0.88),
security: baseSecurity,
exports: [island.resource],
imports: island.resource === 'food' ? ['ore'] : ['food'],
};
});
};
const routeKey = (a, b) => [a.id, b.id].sort().join(':');
const generateTradeRoutes = (random, ports) => {
if (ports.length < 2) return [];
const routes = [];
const connected = new Set([ports[0].id]);
const used = new Set();
while (connected.size < ports.length) {
let best = null;
for (const from of ports) {
if (!connected.has(from.id)) continue;
for (const to of ports) {
if (connected.has(to.id)) continue;
const cost = distance(from, to);
if (!best || cost < best.cost) best = { from, to, cost };
}
}
if (!best) break;
connected.add(best.to.id);
used.add(routeKey(best.from, best.to));
routes.push({ from: best.from, to: best.to });
}
const candidates = [];
for (let i = 0; i < ports.length; i += 1) {
for (let j = i + 1; j < ports.length; j += 1) {
if (used.has(routeKey(ports[i], ports[j]))) continue;
candidates.push({ from: ports[i], to: ports[j], cost: distance(ports[i], ports[j]) });
}
}
candidates.sort((a, b) => a.cost - b.cost);
for (const candidate of candidates.slice(0, Math.max(1, Math.floor(ports.length / 3)))) {
if (random() < 0.72) routes.push(candidate);
}
return routes.map(({ from, to }, index) => {
const traffic = clamp((from.wealth + to.wealth) / 2 + randomBetween(random, -0.08, 0.08), 0.2, 1);
const security = clamp((from.security + to.security) / 2 + randomBetween(random, -0.12, 0.06), 0.1, 0.95);
return {
id: `route-${index + 1}`,
fromPortId: from.id,
toPortId: to.id,
from: { x: from.x, y: from.y },
to: { x: to.x, y: to.y },
traffic,
value: clamp(traffic * ((from.wealth + to.wealth) / 2), 0.1, 1),
security,
width: randomBetween(random, 150, 280),
};
});
};
const generatePirateTerritories = (random, islands, ports, routes, count = 3) => {
if (!islands.length || !routes.length) return [];
const candidates = islands
.filter((island) => !ports.some((port) => port.islandId === island.id))
.map((island) => {
let bestRoute = null;
let routeDistance = Infinity;
for (const route of routes) {
const candidateDistance = pointSegmentDistance(island, route.from, route.to);
if (candidateDistance < routeDistance) {
routeDistance = candidateDistance;
bestRoute = route;
}
}
const remoteness = clamp(nearestPortDistance(island, ports) / 900, 0, 1);
const opportunity = bestRoute ? bestRoute.value * (1 - bestRoute.security * 0.55) : 0;
const proximity = clamp(1 - routeDistance / 1000, 0, 1);
return {
island,
route: bestRoute,
score: opportunity * 0.52 + proximity * 0.28 + remoteness * 0.2 + randomBetween(random, 0, 0.08),
};
})
.sort((a, b) => b.score - a.score)
.slice(0, Math.min(count, Math.max(1, candidatesLength(islands, ports))));
return candidates.map(({ island, route, score }, index) => ({
id: `pirate-territory-${index + 1}`,
name: ['Blackwake Reach', 'Corsair Belt', 'Red Lantern Expanse'][index] || `Pirate Reach ${index + 1}`,
center: { x: island.x, y: island.y },
radius: randomBetween(random, 460, 760),
strength: clamp(0.45 + score * 0.55, 0.45, 1),
aggression: randomBetween(random, 0.62, 0.95),
havenIslandId: island.id,
targetRouteId: route?.id || null,
}));
};
const candidatesLength = (islands, ports) => Math.max(
0,
islands.length - new Set(ports.map((port) => port.islandId)).size,
);
export const generateWorld = ({
seed = 'SKYWARD-001',
width = 3600,
height = 3600,
islandCount = 16,
portCount = 7,
} = {}) => {
const random = createSeededRandom(seed);
const regions = createRegions(random, width, height);
const islands = generateIslands(random, width, height, islandCount);
const ports = generatePorts(random, islands, portCount);
const tradeRoutes = generateTradeRoutes(random, ports);
const pirateTerritories = generatePirateTerritories(random, islands, ports, tradeRoutes, 3);
const starterPort = [...ports].sort((a, b) => b.security - a.security)[0]
|| { x: width / 2, y: height / 2 };
return {
seed,
width,
height,
regions,
islands,
ports,
tradeRoutes,
pirateTerritories,
spawn: {
x: clamp(starterPort.x + 120, 120, width - 120),
y: clamp(starterPort.y + 150, 120, height - 120),
portId: starterPort.id || null,
},
};
};
export const pirateInfluenceAt = (world, x, y) => {
let influence = 0;
for (const territory of world?.pirateTerritories || []) {
const dist = Math.hypot(x - territory.center.x, y - territory.center.y);
if (dist >= territory.radius) continue;
influence = Math.max(influence, (1 - dist / territory.radius) * territory.strength);
}
return clamp(influence, 0, 1);
};
export const nearestPort = (world, x, y) => {
let result = null;
let range = Infinity;
for (const port of world?.ports || []) {
const candidate = Math.hypot(x - port.x, y - port.y);
if (candidate < range) {
result = port;
range = candidate;
}
}
return { port: result, range };
};
export const pirateSpawnPointsForWave = (world, player, count, wave = 1) => {
const territories = [...(world?.pirateTerritories || [])]
.sort((a, b) => distance(a.center, player) - distance(b.center, player));
const territory = territories[(Math.max(1, wave) - 1) % Math.max(1, territories.length)];
const center = territory?.center || player;
const radius = territory ? Math.min(territory.radius * 0.62, 520) : 520;
const random = createSeededRandom(`${world?.seed || 'world'}:wave:${wave}`);
return Array.from({ length: count }, (_, index) => {
const angle = (Math.PI * 2 * index / Math.max(1, count)) + randomBetween(random, -0.28, 0.28);
const spread = radius * randomBetween(random, 0.62, 1);
return {
x: clamp(center.x + Math.cos(angle) * spread, 120, (world?.width || 3600) - 120),
y: clamp(center.y + Math.sin(angle) * spread, 120, (world?.height || 3600) - 120),
angle,
territoryId: territory?.id || null,
};
});
};

View File

@ -0,0 +1,55 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
bestAffordableMarketUpgrade,
buyAndInstallModule,
collectSalvage,
createStarterProgression,
deriveShipStats,
installModule,
salvageForPirate,
} from '../src/progression/ship-loadout.js';
test('starter loadout preserves current ship combat and handling values', () => {
const progression = createStarterProgression();
const stats = deriveShipStats(progression.loadout);
assert.equal(stats.maxHull, 100);
assert.equal(stats.maxSpeed, 180);
assert.equal(stats.turnRate, 76);
assert.equal(stats.reloadMs, 850);
assert.equal(stats.cannonDamage, 16);
});
test('installed modules derive meaningful ship stat changes', () => {
const progression = installModule(createStarterProgression(), 'corsair-overdrive');
const stats = deriveShipStats(progression.loadout);
assert.equal(stats.maxSpeed, 214);
assert.equal(stats.acceleration, 110);
assert.equal(stats.turnRate, 71);
});
test('port purchase deducts credits and installs the module', () => {
const starter = createStarterProgression();
const result = buyAndInstallModule(starter, 'tuned-propellers');
assert.equal(result.purchased, true);
assert.equal(result.credits, starter.credits - 280);
assert.equal(result.loadout.engine, 'tuned-propellers');
});
test('pirate salvage is deterministic and collectible within salvage capacity', () => {
const salvageA = salvageForPirate({ seed: 'WORLD', classKey: 'captain', wave: 4, sequence: 2 });
const salvageB = salvageForPirate({ seed: 'WORLD', classKey: 'captain', wave: 4, sequence: 2 });
assert.deepEqual(salvageA, salvageB);
const starter = createStarterProgression();
const collected = collectSalvage(starter, salvageA);
assert.ok(collected.credits >= starter.credits);
assert.ok(collected.scrap >= starter.scrap);
});
test('market can select an affordable upgrade for a port', () => {
const starter = createStarterProgression();
const upgrade = bestAffordableMarketUpgrade(starter, { type: 'military' });
assert.ok(upgrade);
assert.ok(upgrade.price <= starter.credits);
});

View File

@ -0,0 +1,61 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
generateWorld,
nearestPort,
pirateInfluenceAt,
pirateSpawnPointsForWave,
} from '../src/world/world-generator.js';
test('world generation is deterministic for a seed', () => {
const a = generateWorld({ seed: 'TEST-SEED', width: 3600, height: 3600 });
const b = generateWorld({ seed: 'TEST-SEED', width: 3600, height: 3600 });
assert.deepEqual(a, b);
});
test('different seeds create different geography', () => {
const a = generateWorld({ seed: 'A' });
const b = generateWorld({ seed: 'B' });
assert.notDeepEqual(a.islands, b.islands);
});
test('generated world contains ports, trade routes, and pirate territory', () => {
const world = generateWorld({ seed: 'TRADE-WORLD' });
assert.ok(world.islands.length >= 10);
assert.ok(world.ports.length >= 4);
assert.ok(world.tradeRoutes.length >= world.ports.length - 1);
assert.ok(world.pirateTerritories.length >= 1);
const portIds = new Set(world.ports.map(({ id }) => id));
for (const route of world.tradeRoutes) {
assert.ok(portIds.has(route.fromPortId));
assert.ok(portIds.has(route.toPortId));
assert.ok(route.value > 0);
}
});
test('pirate influence peaks inside generated pirate territory', () => {
const world = generateWorld({ seed: 'BLACKWAKE' });
const territory = world.pirateTerritories[0];
assert.ok(territory);
assert.ok(pirateInfluenceAt(world, territory.center.x, territory.center.y) >= 0.4);
});
test('wave spawns originate around generated pirate territory and remain in bounds', () => {
const world = generateWorld({ seed: 'SPAWN-WORLD' });
const player = { x: world.spawn.x, y: world.spawn.y };
const points = pirateSpawnPointsForWave(world, player, 5, 3);
assert.equal(points.length, 5);
for (const point of points) {
assert.ok(point.x >= 120 && point.x <= world.width - 120);
assert.ok(point.y >= 120 && point.y <= world.height - 120);
assert.ok(point.territoryId);
}
});
test('nearestPort reports the generated starter port nearby', () => {
const world = generateWorld({ seed: 'PORT-WORLD' });
const result = nearestPort(world, world.spawn.x, world.spawn.y);
assert.ok(result.port);
assert.ok(result.range < 400);
});