Add procedural world and ship progression foundations
This commit is contained in:
parent
96b042a4c2
commit
ac07945e92
125
src/progression/modules.js
Normal file
125
src/progression/modules.js
Normal 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);
|
||||||
153
src/progression/ship-loadout.js
Normal file
153
src/progression/ship-loadout.js
Normal 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;
|
||||||
|
};
|
||||||
22
src/world/seeded-random.js
Normal file
22
src/world/seeded-random.js
Normal 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));
|
||||||
262
src/world/world-generator.js
Normal file
262
src/world/world-generator.js
Normal 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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
55
tests/progression.test.mjs
Normal file
55
tests/progression.test.mjs
Normal 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);
|
||||||
|
});
|
||||||
61
tests/world-generator.test.mjs
Normal file
61
tests/world-generator.test.mjs
Normal 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);
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user