diff --git a/src/progression/modules.js b/src/progression/modules.js new file mode 100644 index 0000000..6cc6780 --- /dev/null +++ b/src/progression/modules.js @@ -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); diff --git a/src/progression/ship-loadout.js b/src/progression/ship-loadout.js new file mode 100644 index 0000000..43c3652 --- /dev/null +++ b/src/progression/ship-loadout.js @@ -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; +}; diff --git a/src/world/seeded-random.js b/src/world/seeded-random.js new file mode 100644 index 0000000..6a0cd4a --- /dev/null +++ b/src/world/seeded-random.js @@ -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)); diff --git a/src/world/world-generator.js b/src/world/world-generator.js new file mode 100644 index 0000000..dfe8602 --- /dev/null +++ b/src/world/world-generator.js @@ -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, + }; + }); +}; diff --git a/tests/progression.test.mjs b/tests/progression.test.mjs new file mode 100644 index 0000000..d2fdb49 --- /dev/null +++ b/tests/progression.test.mjs @@ -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); +}); diff --git a/tests/world-generator.test.mjs b/tests/world-generator.test.mjs new file mode 100644 index 0000000..a9fe9e3 --- /dev/null +++ b/tests/world-generator.test.mjs @@ -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); +});