From 580994daeefb31fe3b7fe1080607247ef217c577 Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 20 Sep 2026 08:36:39 +0000 Subject: [PATCH] Complete airship-phaser mirror --- src/world/world-generator.js | 262 ++++++++++++++++++ tests/assets.test.mjs | 117 ++++++++ tests/diagnostics.test.mjs | 49 ++++ tests/environment-physics.test.mjs | 82 ++++++ tests/game-logic.test.mjs | 117 ++++++++ tests/pirate-ai.test.mjs | 103 +++++++ tests/progression.test.mjs | 55 ++++ tests/wiring.test.mjs | 414 +++++++++++++++++++++++++++++ tests/world-generator.test.mjs | 61 +++++ tmp/mcp-import-smoke.txt | 1 + 10 files changed, 1261 insertions(+) create mode 100644 src/world/world-generator.js create mode 100644 tests/assets.test.mjs create mode 100644 tests/diagnostics.test.mjs create mode 100644 tests/environment-physics.test.mjs create mode 100644 tests/game-logic.test.mjs create mode 100644 tests/pirate-ai.test.mjs create mode 100644 tests/progression.test.mjs create mode 100644 tests/wiring.test.mjs create mode 100644 tests/world-generator.test.mjs create mode 100644 tmp/mcp-import-smoke.txt 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/assets.test.mjs b/tests/assets.test.mjs new file mode 100644 index 0000000..b1102ac --- /dev/null +++ b/tests/assets.test.mjs @@ -0,0 +1,117 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import topHud from '../src/assets/controller/top-hud.js'; +import leftStatus from '../src/assets/controller/left-status.js'; +import rightControls from '../src/assets/controller/right-controls.js'; +import cannonRoundel from '../src/assets/controller/cannon-roundel.js'; +import helmFrame from '../src/assets/controller/helm-frame.js'; +import helmKnob from '../src/assets/controller/helm-knob.js'; +import playerHealth from '../src/assets/controller/player-health.js'; +import playerHealthHqFrame from '../src/assets/controller/player-health-hq-frame.js'; +import enemyHealth from '../src/assets/controller/enemy-health.js'; + +const assets = { + topHud, + leftStatus, + rightControls, + cannonRoundel, + helmFrame, + helmKnob, + playerHealth, + enemyHealth, +}; + +const decodeWebP = (name, dataUrl) => { + const match = /^data:image\/webp;base64,([A-Za-z0-9+/=]+)$/.exec(dataUrl); + assert.ok(match, `${name} must be a WebP data URL`); + + const bytes = Buffer.from(match[1], 'base64'); + assert.equal(bytes.subarray(0, 4).toString('ascii'), 'RIFF', `${name} RIFF header`); + assert.equal(bytes.subarray(8, 12).toString('ascii'), 'WEBP', `${name} WEBP signature`); + + const declaredSize = bytes.readUInt32LE(4) + 8; + assert.equal( + bytes.length, + declaredSize, + `${name} must not be truncated: declared ${declaredSize}, actual ${bytes.length}`, + ); + + assert.ok(bytes.length > 100, `${name} should contain real image data`); +}; + +for (const [name, dataUrl] of Object.entries(assets)) { + test(`${name} WebP payload is structurally complete`, () => { + decodeWebP(name, dataUrl); + }); +} + +const alphaAssets = { + topHud, + playerHealth, + enemyHealth, +}; + +for (const [name, dataUrl] of Object.entries(alphaAssets)) { + test(`${name} carries WebP transparency data`, () => { + const match = /^data:image\/webp;base64,([A-Za-z0-9+/=]+)$/.exec(dataUrl); + assert.ok(match, `${name} must be a WebP data URL`); + const bytes = Buffer.from(match[1], 'base64'); + const hasAlphaChunk = bytes.includes(Buffer.from('ALPH')); + const usesLosslessWebP = bytes.includes(Buffer.from('VP8L')); + assert.ok(hasAlphaChunk || usesLosslessWebP, `${name} should preserve transparent background data`); + }); +} + +const webpDimensions = (name, dataUrl) => { + const match = /^data:image\/webp;base64,([A-Za-z0-9+/=]+)$/.exec(dataUrl); + assert.ok(match, `${name} must be a WebP data URL`); + const bytes = Buffer.from(match[1], 'base64'); + + let offset = 12; + while (offset + 18 <= bytes.length) { + const type = bytes.subarray(offset, offset + 4).toString('ascii'); + const size = bytes.readUInt32LE(offset + 4); + const dataOffset = offset + 8; + + if (type === 'VP8X') { + const width = + 1 + + bytes[dataOffset + 4] + + (bytes[dataOffset + 5] << 8) + + (bytes[dataOffset + 6] << 16); + const height = + 1 + + bytes[dataOffset + 7] + + (bytes[dataOffset + 8] << 8) + + (bytes[dataOffset + 9] << 16); + return { width, height }; + } + + offset = dataOffset + size + (size % 2); + } + + assert.fail(`${name} must expose VP8X dimensions`); +}; + +test('playerHealth native frame preserves usable HUD resolution', () => { + const { width, height } = webpDimensions('playerHealth', playerHealth); + assert.ok(width >= 300, `playerHealth width must stay >= 300px, got ${width}px`); + assert.ok(height >= 96, `playerHealth height must stay >= 96px, got ${height}px`); +}); + +test('enemyHealth health sheet stays high resolution', () => { + const { width, height } = webpDimensions('enemyHealth', enemyHealth); + assert.ok(width >= 1200, `enemyHealth width must stay >= 1200px, got ${width}px`); + assert.ok(height >= 300, `enemyHealth height must stay >= 300px, got ${height}px`); + assert.ok(width / 4 >= 300, 'enemyHealth frame width must stay >= 300px'); + assert.ok(height / 3 >= 100, 'enemyHealth frame height must stay >= 100px'); +}); + + +test('high-resolution player health frame is complete and retina-ready', () => { + decodeWebP('playerHealthHqFrame', playerHealthHqFrame); + const { width, height } = webpDimensions('playerHealthHqFrame', playerHealthHqFrame); + assert.ok(width >= 600, `playerHealthHqFrame width must stay >= 600px, got ${width}px`); + assert.ok(height >= 216, `playerHealthHqFrame height must stay >= 216px, got ${height}px`); +}); diff --git a/tests/diagnostics.test.mjs b/tests/diagnostics.test.mjs new file mode 100644 index 0000000..a019522 --- /dev/null +++ b/tests/diagnostics.test.mjs @@ -0,0 +1,49 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + airshipLog, + clearAirshipLogs, + getAirshipLogs, +} from '../src/diagnostics.js'; + +test('diagnostics records structured entries and returns copies', () => { + clearAirshipLogs(); + + const originalLog = console.log; + console.log = () => {}; + try { + airshipLog('test:event', { value: 42 }); + } finally { + console.log = originalLog; + } + + const logs = getAirshipLogs(); + assert.equal(logs.length, 1); + assert.equal(logs[0].event, 'test:event'); + assert.equal(logs[0].value, 42); + assert.equal(logs[0].level, 'info'); + assert.match(logs[0].ts, /^\d{4}-\d{2}-\d{2}T/); + + logs[0].value = 99; + assert.equal(getAirshipLogs()[0].value, 42); +}); + +test('diagnostics ring buffer is capped at 300 entries', () => { + clearAirshipLogs(); + + const originalLog = console.log; + console.log = () => {}; + try { + for (let i = 0; i < 305; i += 1) { + airshipLog('test:ring', { i }); + } + } finally { + console.log = originalLog; + } + + const logs = getAirshipLogs(); + assert.equal(logs.length, 300); + assert.equal(logs[0].i, 5); + assert.equal(logs.at(-1).i, 304); +}); diff --git a/tests/environment-physics.test.mjs b/tests/environment-physics.test.mjs new file mode 100644 index 0000000..73c63ae --- /dev/null +++ b/tests/environment-physics.test.mjs @@ -0,0 +1,82 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + ENVIRONMENT_ISLANDS, + environmentWindAt, + semiRandomWindPattern, + velocityWithEnvironment, +} from '../src/environment-physics.js'; + +test('environment exposes collidable island geometry', () => { + assert.equal(ENVIRONMENT_ISLANDS.length, 2); + for (const island of ENVIRONMENT_ISLANDS) { + assert.ok(island.radius > 0); + assert.ok(island.turbulenceRadius > island.radius); + } +}); + +test('semi-random wind varies strength and direction across weather windows', () => { + const samples = [0, 16000, 32000, 48000, 64000] + .map((timeMs) => semiRandomWindPattern(timeMs)); + + const roundedStrengths = new Set(samples.map(({ strength }) => Math.round(strength * 10))); + const roundedHeadings = new Set(samples.map(({ angle }) => Math.round(angle * 10))); + + assert.ok(roundedStrengths.size >= 3); + assert.ok(roundedHeadings.size >= 3); + for (const sample of samples) { + assert.ok(sample.strength >= 4 && sample.strength < 28); + } +}); + +test('semi-random wind changes smoothly instead of jumping each frame', () => { + const before = semiRandomWindPattern(15950); + const after = semiRandomWindPattern(16050); + const vector = ({ angle, strength }) => ({ + x: Math.cos(angle) * strength, + y: Math.sin(angle) * strength, + }); + const a = vector(before); + const b = vector(after); + + assert.ok(Math.hypot(b.x - a.x, b.y - a.y) < 2.5); +}); + +test('ambient wind changes over time and remains bounded', () => { + const first = environmentWindAt({ x: 1200, y: 1200, timeMs: 0 }); + const later = environmentWindAt({ x: 1200, y: 1200, timeMs: 24000 }); + + assert.ok(first.strength > 3 && first.strength < 30); + assert.ok(later.strength > 3 && later.strength < 30); + assert.notDeepEqual( + [Math.round(first.x * 100), Math.round(first.y * 100)], + [Math.round(later.x * 100), Math.round(later.y * 100)], + ); +}); + +test('islands produce a local turbulence contribution', () => { + const island = ENVIRONMENT_ISLANDS[0]; + const near = environmentWindAt({ + x: island.x + island.radius + 20, + y: island.y, + timeMs: 6000, + }); + const far = environmentWindAt({ + x: island.x + island.turbulenceRadius + 300, + y: island.y, + timeMs: 6000, + }); + + assert.notEqual( + Math.round(near.strength * 100), + Math.round(far.strength * 100), + ); +}); + +test('environment velocity adds wind according to exposure', () => { + const result = velocityWithEnvironment(100, -20, { x: 12, y: 8 }, 0.5); + + assert.equal(result.x, 106); + assert.equal(result.y, -16); +}); diff --git a/tests/game-logic.test.mjs b/tests/game-logic.test.mjs new file mode 100644 index 0000000..d0513ba --- /dev/null +++ b/tests/game-logic.test.mjs @@ -0,0 +1,117 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + DEFAULT_PIRATE_SHOT_DAMAGE, + PLAYER_MAX_HULL, + normalizePlayerDamage, + reducePlayerProjectileHits, + resolveTaggedProjectile, +} from '../src/game-logic.js'; + +test('first pirate projectile removes exactly the default damage', () => { + const result = reducePlayerProjectileHits({ + hull: PLAYER_MAX_HULL, + hits: [{ damage: DEFAULT_PIRATE_SHOT_DAMAGE }], + }); + + assert.equal(result.hull, 94); + assert.equal(result.appliedHits, 1); + assert.equal(result.totalDamage, 6); + assert.equal(result.gameOver, false); +}); + +test('damage normalization handles invalid and extreme values safely', () => { + assert.equal(normalizePlayerDamage(undefined), 6); + assert.equal(normalizePlayerDamage('not-a-number'), 6); + assert.equal(normalizePlayerDamage(999), 25); + assert.equal(normalizePlayerDamage(-12), 1); + assert.equal(normalizePlayerDamage('8'), 8); +}); + +test('invalid hull state recovers from max hull before applying projectile damage', () => { + const result = reducePlayerProjectileHits({ + hull: Number.NaN, + hits: [{ damage: 6 }], + }); + + assert.equal(result.hull, 94); + assert.equal(result.gameOver, false); +}); + +test('lethal projectile damage clamps hull at zero and reports game over', () => { + const result = reducePlayerProjectileHits({ + hull: 5, + hits: [{ damage: 6 }], + }); + + assert.equal(result.hull, 0); + assert.equal(result.totalDamage, 6); + assert.equal(result.gameOver, true); +}); + +test('empty projectile queue is a no-op', () => { + const result = reducePlayerProjectileHits({ + hull: 72, + hits: [], + }); + + assert.equal(result.hull, 72); + assert.equal(result.totalDamage, 0); + assert.equal(result.appliedHits, 0); + assert.equal(result.gameOver, false); +}); + +test('every distinct pirate projectile can damage once with no cross-shot suppression', () => { + const first = reducePlayerProjectileHits({ + hull: 100, + hits: [{ damage: 6 }], + }); + const second = reducePlayerProjectileHits({ + hull: first.hull, + hits: [{ damage: 6 }], + }); + + assert.equal(first.hull, 94); + assert.equal(second.hull, 88); +}); + +test('multiple distinct pirate projectiles in the same frame all apply damage', () => { + const result = reducePlayerProjectileHits({ + hull: 100, + hits: [ + { damage: 6 }, + { damage: 6 }, + { damage: 6 }, + ], + }); + + assert.equal(result.hull, 82); + assert.equal(result.totalDamage, 18); + assert.equal(result.appliedHits, 3); +}); + +test('projectile resolver is independent of Phaser overlap argument order', () => { + const player = { + getData() { return undefined; }, + }; + const pirateShot = { + getData(key) { + return key === 'projectileOwner' ? 'pirate' : undefined; + }, + }; + + assert.equal(resolveTaggedProjectile(pirateShot, player, 'pirate'), pirateShot); + assert.equal(resolveTaggedProjectile(player, pirateShot, 'pirate'), pirateShot); +}); + +test('projectile resolver never mistakes the player for an untagged shot', () => { + const player = { + getData() { return undefined; }, + }; + const untagged = { + getData() { return undefined; }, + }; + + assert.equal(resolveTaggedProjectile(player, untagged, 'pirate'), null); +}); diff --git a/tests/pirate-ai.test.mjs b/tests/pirate-ai.test.mjs new file mode 100644 index 0000000..c7c2ab8 --- /dev/null +++ b/tests/pirate-ai.test.mjs @@ -0,0 +1,103 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + PIRATE_CLASSES, + PIRATE_CLASS_COSTS, + pirateDesiredHeading, + pirateRosterForWave, + pirateSpeedForRange, + separationVector, + waveDifficultyBudget, +} from '../src/pirate-ai.js'; + +const rosterCost = (roster) => roster.reduce( + (sum, pirateClass) => sum + PIRATE_CLASS_COSTS[pirateClass.key], + 0, +); + +test('pirate classes expose distinct combat roles and projectile damage', () => { + assert.ok(PIRATE_CLASSES.raider.speed > PIRATE_CLASSES.frigate.speed); + assert.ok(PIRATE_CLASSES.raider.hull < PIRATE_CLASSES.frigate.hull); + assert.ok(PIRATE_CLASSES.captain.hull > PIRATE_CLASSES.frigate.hull); + assert.ok(PIRATE_CLASSES.captain.projectileDamage > PIRATE_CLASSES.frigate.projectileDamage); +}); + +test('wave roster uses a monotonic difficulty budget with a six-ship cap', () => { + assert.deepEqual( + pirateRosterForWave(1).map(({ key }) => key), + ['frigate'], + ); + assert.deepEqual( + pirateRosterForWave(2).map(({ key }) => key), + ['frigate', 'raider'], + ); + assert.deepEqual( + pirateRosterForWave(4).map(({ key }) => key), + ['captain', 'raider', 'raider', 'raider'], + ); + + let previousCost = 0; + for (let wave = 1; wave <= 12; wave += 1) { + const roster = pirateRosterForWave(wave); + const cost = rosterCost(roster); + + assert.ok(roster.length <= 6); + assert.ok(cost <= waveDifficultyBudget(wave)); + assert.ok(cost >= previousCost, `wave ${wave} should not get cheaper`); + previousCost = cost; + } +}); + +test('pirates retreat away from the player when inside retreat range', () => { + const heading = pirateDesiredHeading({ + bearing: 20, + currentHeading: 90, + distance: 150, + preferredRange: 335, + retreatRange: 210, + }); + + assert.equal(heading, 200); +}); + +test('pirates choose the closest broadside heading inside preferred range', () => { + const heading = pirateDesiredHeading({ + bearing: 0, + currentHeading: 80, + distance: 300, + preferredRange: 335, + retreatRange: 210, + }); + + assert.equal(heading, 90); +}); + +test('retreat speed keeps pirates moving out of close range', () => { + const retreat = pirateSpeedForRange({ + distance: 150, + preferredRange: 335, + retreatRange: 210, + baseSpeed: 118, + }); + const broadside = pirateSpeedForRange({ + distance: 300, + preferredRange: 335, + retreatRange: 210, + baseSpeed: 118, + }); + + assert.ok(retreat > broadside); +}); + +test('separation steering pushes nearby ships apart and ignores distant ships', () => { + const ship = { x: 100, y: 100, active: true }; + const close = { x: 150, y: 100, active: true }; + const far = { x: 500, y: 100, active: true }; + + const vector = separationVector(ship, [ship, close, far], 125); + + assert.ok(vector.x < 0, 'ship should steer away from a close ship on its right'); + assert.equal(vector.y, 0); + assert.ok(Math.hypot(vector.x, vector.y) <= 1); +}); 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/wiring.test.mjs b/tests/wiring.test.mjs new file mode 100644 index 0000000..0d3b3f8 --- /dev/null +++ b/tests/wiring.test.mjs @@ -0,0 +1,414 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; + +const read = (path) => readFile(new URL(`../${path}`, import.meta.url), 'utf8'); + +test('mobile HUD uses split helm frame and knob assets', async () => { + const source = await read('src/mobile-hud.js'); + + assert.match(source, /import helmFrameSrc from '.\/assets\/controller\/helm-frame\.js'/); + assert.match(source, /import helmKnobSrc from '.\/assets\/controller\/helm-knob\.js'/); + assert.doesNotMatch(source, /helmSpriteSheetSrc/); + assert.doesNotMatch(source, /helm-sheet\.webp/); + assert.doesNotMatch(source, /backgroundPosition = .*column/); +}); + +test('drag control emits and HUD consumes one shared helm vector event', async () => { + const [index, hud] = await Promise.all([ + read('index.html'), + read('src/mobile-hud.js'), + ]); + + assert.match(index, /CustomEvent\('airship:helm-vector'/); + assert.match(index, /emitHelmVector\(dx \/ radius, dy \/ radius\)/); + assert.match(index, /emitHelmVector\(0, 0\)/); + assert.doesNotMatch(index, /setHeld\('Arrow/); + assert.doesNotMatch(index, /keyMap/); + + assert.match(hud, /addEventListener\('airship:helm-vector'/); + assert.match(hud, /setHelmKnob\(event\.detail\?\.x \?\? 0, event\.detail\?\.y \?\? 0\)/); +}); + +test('top HUD and cannon controls use direct image modules', async () => { + const source = await read('src/mobile-hud.js'); + + assert.match(source, /import topHudSrc from '.\/assets\/controller\/top-hud\.js'/); + assert.match(source, /import cannonRoundelSrc from '.\/assets\/controller\/cannon-roundel\.js'/); + assert.match(source, /addPiece\('steam-top', topHudSrc/); + assert.match(source, /addPiece\('steam-cannon-port', cannonRoundelSrc/); + assert.match(source, /addPiece\('steam-cannon-starboard', cannonRoundelSrc/); +}); + +test('player collision callback queues hits and processing occurs in scene update', async () => { + const source = await read('src/game-v4.js'); + + assert.match(source, /this\.pendingPlayerHits\.push\(\{[\s\S]*?shot,[\s\S]*?damage,[\s\S]*?x: shot\.x,[\s\S]*?y: shot\.y/); + assert.match(source, /this\.processPlayerHits\(\)/); + assert.match(source, /reducePlayerProjectileHits\(\{/); + assert.match(source, /combat:player-hit-queued/); + assert.match(source, /combat:player-hit-processed/); +}); + +test('wide-screen HUD keeps visual controls aligned with touch hit-zones', async () => { + const source = await read('src/mobile-hud.js'); + + assert.match(source, /@media \(min-width: 700px\)/); + assert.match(source, /--steam-wide-helm-size:/); + assert.match(source, /--steam-wide-cannon-size:/); + assert.match(source, /\.steam-radar-wrap,\s*#mobile-controls\.controller-active #drag-stick/); + assert.match(source, /\.steam-cannon-port,\s*\.steam-cannon-starboard,\s*#mobile-controls\.controller-active \.mobile-control\.port,\s*#mobile-controls\.controller-active \.mobile-control\.starboard/); + assert.match(source, /right: calc\(var\(--steam-wide-edge-right\) \+ var\(--steam-wide-cannon-size\) \+ 14px\)/); +}); + +test('wide-screen HUD caps portrait-scaled artwork instead of stretching with viewport width', async () => { + const source = await read('src/mobile-hud.js'); + + assert.match(source, /\.steam-top \{[\s\S]*?width: min\(64vw, 560px\)/); + assert.match(source, /@media \(min-width: 700px\)[\s\S]*?\.steam-player-card \{[\s\S]*?width: min\(18vw, 180px\)/); + assert.match(source, /@media \(min-width: 700px\)[\s\S]*?\.steam-enemy-card \{[\s\S]*?width: min\(18vw, 180px\)/); +}); + +test('non-lethal player hit processing does not mutate the player object or game state', async () => { + const source = await read('src/game-v4.js'); + const match = /processPlayerHits\(\) \{([\s\S]*?)\n spawnExplosion\(/.exec(source); + assert.ok(match, 'processPlayerHits block should be found'); + + const block = match[1]; + assert.match(block, /this\.playerHull = result\.hull/); + assert.match(block, /if \(gameOver\) this\.finishGame\(\)/); + + assert.doesNotMatch(block, /this\.player\.setVelocity\(/); + assert.doesNotMatch(block, /this\.player\.setVisible\(/); + assert.doesNotMatch(block, /this\.player\.setActive\(/); + assert.doesNotMatch(block, /this\.player\.destroy\(/); + assert.doesNotMatch(block, /this\.player\.body\.enable\s*=/); + assert.doesNotMatch(block, /this\.gameState\s*=/); +}); + +test('a hit frame emits a completion marker after the rest of scene update runs', async () => { + const source = await read('src/game-v4.js'); + + assert.match(source, /const hadQueuedPlayerHit = this\.pendingPlayerHits\.length > 0/); + assert.match(source, /this\.checkGameState\(\);[\s\S]*?combat:post-hit-frame-complete/); +}); + +test('player hit FX runs after queued hit processing, outside the overlap callback', async () => { + const source = await read('src/game-v4.js'); + + assert.match(source, /this\.pendingPlayerHits\.push\(\{[\s\S]*?x: shot\.x,[\s\S]*?y: shot\.y/); + assert.match(source, /if \(appliedHits > 0\) \{[\s\S]*?for \(const impact of hits\) \{[\s\S]*?this\.spawnExplosion\(impact\.x, impact\.y, 0\.32\)/); + assert.match(source, /this\.playPlayerHitReaction\(totalDamage\)/); + assert.match(source, /this\.cameras\.main\.shake\(120/); + assert.doesNotMatch(source, /targets: this\.player/); + assert.match(source, /combat:player-hit-fx-error/); +}); + + +test('player damage is per projectile with obsolete cooldown and volley models removed', async () => { + const [source, logic] = await Promise.all([ + read('src/game-v4.js'), + read('src/game-logic.js'), + ]); + + assert.match(source, /if \(shot\.getData\('playerHitQueued'\)\) return/); + assert.match(source, /reducePlayerProjectileHits\(\{/); + assert.doesNotMatch(logic, /PLAYER_HIT_COOLDOWN_MS/); + assert.doesNotMatch(logic, /reducePlayerHitQueue/); + assert.doesNotMatch(logic, /reducePlayerVolleyHits/); + assert.doesNotMatch(logic, /processQueuedPlayerHits/); +}); + +test('portrait health sprites sit tightly beneath the top HUD', async () => { + const source = await read('src/mobile-hud.js'); + + assert.match(source, /\.steam-player-card,[\s\S]*?\.steam-enemy-card \{[\s\S]*?top: clamp\(80px, 10\.3vh, 90px\)/); + assert.match(source, /background-size: 400% 300%/); + assert.match(source, /background-color: transparent/); + assert.match(source, /@media \(min-width: 700px\)[\s\S]*?\.steam-player-card,[\s\S]*?\.steam-enemy-card \{[\s\S]*?top: clamp\(70px, 20vh, 104px\)/); +}); + +test('player health card uses the fresh high-resolution sprite sheet', async () => { + const source = await read('src/mobile-hud.js'); + + assert.match(source, /import playerHealthSrc from '.\/assets\/controller\/player-health-fresh-sheet\.png'/); + assert.match(source, /import enemyHealthSrc from '.\/assets\/controller\/enemy-health\.js'/); + assert.match(source, /addHealthPiece\('steam-player-card', playerHealthSrc/); + assert.match(source, /addHealthPiece\('steam-enemy-card', enemyHealthSrc/); + assert.match(source, /\.steam-health-art \{[\s\S]*?background-size: 400% 300%/); + assert.match(source, /setHealthFrame\(\s*playerHealthPiece,\s*healthFrame\(playerHull, playerMaxHull\)/); + assert.doesNotMatch(source, /steam-player-badge/); + assert.doesNotMatch(source, /scaleX\(-1\)/); +}); + +test('uploaded top HUD uses its new aspect ratio', async () => { + const source = await read('src/mobile-hud.js'); + + assert.match(source, /\.steam-top \{[\s\S]*?aspect-ratio: 960 \/ 246/); + assert.match(source, /@media \(min-width: 700px\)[\s\S]*?\.steam-top \{[\s\S]*?aspect-ratio: 960 \/ 246/); +}); + +test('top HUD stat overlays use bounded tabular value slots', async () => { + const source = await read('src/mobile-hud.js'); + + assert.match(source, /\.steam-top-value \{[\s\S]*?font-variant-numeric: tabular-nums/); + assert.match(source, /\.steam-top-value \{[\s\S]*?font-feature-settings: 'tnum' 1/); + assert.match(source, /\.steam-speed \{ left: 9\.3%; width: 7%; \}/); + assert.match(source, /\.steam-heading \{ left: 21\.2%; width: 12%; \}/); + assert.match(source, /\.steam-range \{ left: 91\.1%; width: 12%; \}/); + assert.match(source, /\.steam-top-value \{[\s\S]*?top: 63%/); + assert.match(source, /\.steam-wave \{ left: 50%; top: 67%; width: 8%; \}/); + assert.match(source, /@media \(min-width: 700px\)[\s\S]*?\.steam-top-value \{[\s\S]*?font-size: clamp\(9px, 1\.15vw, 13px\)/); +}); + + +test('game consumes analog helm vector and keeps broadside cooldowns independent', async () => { + const source = await read('src/game-v4.js'); + + assert.match(source, /addEventListener\('airship:helm-vector', this\.handleHelmVector\)/); + assert.match(source, /const rawHelmX = Math\.abs\(this\.helmVector\.x\) > HELM_DEAD_ZONE/); + assert.match(source, /Math\.pow\(Math\.abs\(rawHelmX\), HELM_STEER_CURVE\)/); + assert.match(source, /const throttleForward = this\.cursors\.up\.isDown \? 1 : Math\.max\(0, -helmY\)/); + assert.match(source, /const turnInput = keyboardTurn \|\| helmX/); + assert.match(source, /const broadside = side < 0 \? 'port' : 'starboard'/); + assert.match(source, /this\.lastPlayerShot\[broadside\]/); +}); + +test('player firing and reload HUD share one reload duration source', async () => { + const source = await read('src/game-v4.js'); + + assert.match(source, /this\.playerBroadsideReloadMs = PLAYER_BROADSIDE_RELOAD_MS/); + assert.match(source, /this\.time\.now - this\.lastPlayerShot\[broadside\] < this\.playerBroadsideReloadMs/); + assert.match(source, /Math\.max\(1, this\.playerBroadsideReloadMs\)/); +}); + +test('player and pirate broadsides use the cannonball image asset', async () => { + const source = await read('src/game-v4.js'); + + assert.match(source, /import cannonballUrl from '.\/assets\/cannonball\.png\?url'/); + assert.match(source, /this\.load\.image\('cannonball', cannonballUrl\)/); + assert.match(source, /this\.fireBroadside\(this\.player, side, this\.playerShots, 'cannonball'/); + assert.match(source, /this\.fireBroadside\(pirate, side, this\.pirateShots, 'cannonball'/); + assert.match(source, /shot\.setDepth\(8\)\.setDisplaySize\(14, 14\)/); + assert.doesNotMatch(source, /generateTexture\('player-shot'/); + assert.doesNotMatch(source, /generateTexture\('pirate-shot'/); +}); + +test('pirate cannonballs damage other pirates but ignore their source ship', async () => { + const source = await read('src/game-v4.js'); + + assert.match(source, /this\.physics\.add\.overlap\(this\.pirateShots, this\.pirates, this\.hitPirate/); + assert.match(source, /shot\.setData\('sourceShip', ship\)/); + assert.match(source, /const pirateShot = playerShot \? null : resolveTaggedProjectile\(first, second, 'pirate'\)/); + assert.match(source, /if \(projectileOwner === 'pirate' && sourceShip === pirate\) return/); + assert.match(source, /friendlyFire: projectileOwner === 'pirate'/); + assert.match(source, /if \(projectileOwner === 'player'\) this\.defeated \+= 1/); +}); + +test('player uses the four-frame north-facing sheet as a forward propulsion animation', async () => { + const source = await read('src/game-v4.js'); + + assert.match(source, /import playerAirshipUrl from '.\/assets\/player-airship-4frame\.png\?url'/); + assert.match(source, /this\.load\.spritesheet\('player-airship', playerAirshipUrl, \{[\s\S]*?frameWidth: 128,[\s\S]*?frameHeight: 256/); + assert.match(source, /this\.physics\.add\.sprite\(1200, 1550, 'player-airship', 0\)/); + assert.match(source, /setDisplaySize\(66, 126\)/); + assert.match(source, /this\.player\.body\.setCircle\(44, 20, 84\)/); + assert.match(source, /key: 'player-forward'/); + assert.match(source, /generateFrameNumbers\('player-airship', \{ start: 0, end: 3 \}\)/); + assert.match(source, /const forwardThrust = clamp\(this\.playerSpeed \/ PLAYER_MAX_SPEED, 0, 1\)/); + assert.match(source, /this\.player\.anims\.timeScale = 0\.65 \+ forwardThrust \* 1\.35/); + assert.match(source, /this\.player\.setFrame\(0\)/); + assert.doesNotMatch(source, /key: 'player-idle'/); +}); + +test('player propulsion smoke emits at world engine positions with per-particle trailing velocity', async () => { + const source = await read('src/game-v4.js'); + + assert.match(source, /this\.createPlayerSmoke\(\)/); + assert.match(source, /this\.add\.particles\(0, 0, 'player-smoke'/); + assert.match(source, /frequency: -1/); + assert.match(source, /this\.updatePlayerSmoke\(deltaMs\)/); + assert.match(source, /const forwardThrust = clamp\(this\.playerSpeed \/ PLAYER_MAX_SPEED, 0, 1\)/); + assert.match(source, /if \(this\.gameState !== 'playing' \|\| forwardThrust <= 0\.04\)/); + assert.match(source, /const sternX = this\.player\.x - forwardX \* PLAYER_SMOKE_REAR_OFFSET/); + assert.match(source, /const sternY = this\.player\.y - forwardY \* PLAYER_SMOKE_REAR_OFFSET/); + assert.match(source, /PLAYER_SMOKE_ENGINE_OFFSET \* engineSide/); + assert.match(source, /const particle = this\.playerSmoke\.emitParticleAt\(engineX, engineY, 1\)/); + assert.match(source, /particle\.velocityX = velocityX/); + assert.match(source, /particle\.velocityY = velocityY/); + assert.match(source, /Phaser\.Math\.Linear\(150, 48, forwardThrust\)/); + assert.doesNotMatch(source, /this\.playerSmoke\.setPosition\(sternX, sternY\)/); + assert.doesNotMatch(source, /this\.playerSmoke\.setParticleSpeed\(/); +}); + +test('environment physics adds wind, soft island collision, turbulence, and projectile interaction', async () => { + const source = await read('src/game-v4.js'); + + assert.match(source, /ENVIRONMENT_ISLANDS/); + assert.match(source, /environmentWindAt/); + assert.match(source, /velocityWithEnvironment/); + assert.match(source, /this\.createEnvironmentPhysics\(\)/); + assert.match(source, /this\.physics\.add\.staticImage/); + assert.match(source, /obstacle\.body\.setCircle\(island\.radius\)/); + assert.match(source, /this\.physics\.add\.collider\(this\.player, obstacle, \(\) => \{[\s\S]*?this\.playerSpeed \*= 0\.7;/); + assert.match(source, /this\.physics\.add\.collider\(this\.pirates, obstacle\)/); + assert.match(source, /this\.physics\.add\.overlap\(\s*this\.playerShots,[\s\S]*?this\.hitEnvironmentProjectile/); + assert.match(source, /this\.environmentWind = environmentWindAt\(\{/); + assert.match(source, /const pirateWind = environmentWindAt\(\{/); + assert.match(source, /const shotWind = environmentWindAt\(\{/); + assert.match(source, /const localWind = environmentWindAt\(\{/); + assert.match(source, /windSpeed: this\.environmentWind\?\.strength/); +}); + +test('rolling clouds include large dense banks that phase in and out with the wind', async () => { + const source = await read('src/game-v4.js'); + + assert.match(source, /const ROLLING_CLOUD_COUNT = 34/); + assert.match(source, /const DENSE_CLOUD_MIN_COUNT = 8/); + assert.match(source, /const DENSE_CLOUD_MIN_SCALE = 2\.2/); + assert.match(source, /const DENSE_CLOUD_MAX_SCALE = 3\.9/); + assert.match(source, /const DENSE_CLOUD_CONCEALMENT_RADIUS_SCALE = 82/); + assert.match(source, /Phaser\.Math\.FloatBetween\(DENSE_CLOUD_MIN_SCALE, DENSE_CLOUD_MAX_SCALE\)/); + assert.match(source, /cloud\.setData\(\s*'concealmentRadius',[\s\S]*?DENSE_CLOUD_CONCEALMENT_RADIUS_SCALE/); + assert.match(source, /cloud\.setData\('weatherCycleMs', Phaser\.Math\.Between\(18000, 36000\)\)/); + assert.match(source, /const windPresence = clamp\(\(wind\.strength - 5\) \/ 18, 0, 1\)/); + assert.match(source, /const targetPresence = clamp\(weatherWave \* 0\.72 \+ windPresence \* 0\.38 - 0\.12, 0, 1\)/); + assert.match(source, /cloud\.setAlpha\(Phaser\.Math\.Linear\(alphaMin, alphaMax, visibility\) \* presence\)/); + assert.match(source, /this\.reseedDenseCloudUpwind\(cloud, wind\)/); + assert.match(source, /cloud\.x = centerX - windX \* \(WORLD_SIZE \/ 2 \+ margin\)/); + assert.match(source, /cloud\.y = centerY - windY \* \(WORLD_SIZE \/ 2 \+ margin\)/); + assert.doesNotMatch(source, /const clouds = this\.add\.graphics\(\)/); +}); + +test('dense clouds conceal ships and suppress long-range pirate fire', async () => { + const source = await read('src/game-v4.js'); + + assert.match(source, /cloudConcealmentAt\(x, y\)/); + assert.match(source, /cloud\.getData\('denseCloud'\)/); + assert.match(source, /Phaser\.Math\.Distance\.Between\(x, y, cloud\.x, cloud\.y\)/); + assert.match(source, /this\.updateCloudConcealment\(\)/); + assert.match(source, /this\.player\.setAlpha\(Phaser\.Math\.Linear\(1, 0\.3, playerConcealment\)\)/); + assert.match(source, /pirate\.setAlpha\(Phaser\.Math\.Linear\(1, 0\.36, concealment\)\)/); + assert.match(source, /this\.player\.setData\('cloudConcealment', playerConcealment\)/); + assert.match(source, /const presence = cloud\.getData\('presence'\) \?\? 1/); + assert.match(source, /radialStrength \* strength \* presence/); + assert.match(source, /const targetObscured = playerConcealment >= CLOUD_FIRE_SUPPRESSION_CONCEALMENT/); + assert.match(source, /distance > CLOUD_CLOSE_ENGAGE_RANGE/); + assert.match(source, /if \(!targetObscured && distance < broadsideRange/); + assert.match(source, /concealment: this\.player\?\.getData\?\.\('cloudConcealment'\) \|\| 0/); +}); + +test('mobile HUD skips hidden Phaser rendering and throttles DOM state publishing', async () => { + const source = await read('src/game-v4.js'); + + assert.match(source, /const HUD_PUBLISH_INTERVAL_MS = 50/); + assert.match(source, /if \(!this\.mobileHudActive\) this\.drawHud\(hudState\)/); + assert.match(source, /publishHudState\(state = this\.buildHudState\(\), force = false\)/); + assert.match(source, /if \(!this\.mobileHudActive \|\| !state\) return/); + assert.match(source, /now - this\.lastHudPublishAt < HUD_PUBLISH_INTERVAL_MS/); + assert.match(source, /drawHud\(state = this\.buildHudState\(\)\)/); +}); + + +test('pirate combat uses budgeted class stats, retreat steering, and separation', async () => { + const source = await read('src/game-v4.js'); + + assert.match(source, /pirateRosterForWave\(this\.wave, MAX_PIRATES\)/); + assert.match(source, /pirateDesiredHeading\(\{/); + assert.match(source, /pirateSpeedForRange\(\{/); + assert.match(source, /separationVector\(pirate, pirates\)/); + assert.match(source, /pirate\.getData\('reloadMs'\)/); + assert.match(source, /pirate\.getData\('projectileDamage'\)/); + assert.match(source, /pirate\.getData\('maxHull'\)/); +}); + +test('mobile cannon HUD shows independent reload progress', async () => { + const source = await read('src/mobile-hud.js'); + + assert.match(source, /data-steam="port-reload"/); + assert.match(source, /data-steam="starboard-reload"/); + assert.match(source, /state\.reload\?\.port/); + assert.match(source, /state\.reload\?\.starboard/); +}); + + +test('mobile HUD is driven by authoritative game state events', async () => { + const [game, hud] = await Promise.all([ + read('src/game-v4.js'), + read('src/mobile-hud.js'), + ]); + + assert.match(game, /CustomEvent\('airship:hud-state', \{ detail: state \}\)/); + assert.match(game, /hostiles: activePirates\.length/); + assert.match(game, /hitSequence: this\.playerHitSequence/); + assert.match(hud, /addEventListener\('airship:hud-state'/); + assert.match(hud, /renderHudState\(event\.detail\)/); + assert.doesNotMatch(hud, /Phaser\.GAMES/); + assert.doesNotMatch(hud, /getScene\(\)/); +}); + +test('processed player hits drive sprite and HUD hit reactions', async () => { + const [game, hud] = await Promise.all([ + read('src/game-v4.js'), + read('src/mobile-hud.js'), + ]); + + assert.match(game, /this\.playerHitSequence \+= 1/); + assert.match(game, /playPlayerHitReaction\(damage = DEFAULT_PIRATE_SHOT_DAMAGE\)/); + assert.match(game, /this\.player\.setTint\(0xffd7d7\)/); + assert.match(game, /this\.cameras\.main\.shake\(120/); + assert.doesNotMatch(game, /targets: this\.player/); + assert.doesNotMatch(game, /scaleX: this\.playerBaseScaleX/); + assert.match(hud, /steam-player-card\.steam-hit/); + assert.match(hud, /pulsePlayerHit\(state\.player\.hitSequence \?\? 0\)/); +}); + + +test('processed pirate shots are consumed and later projectiles start clean', async () => { + const source = await read('src/game-v4.js'); + + assert.match(source, /shot\.setData\('projectileOwner', actor\)/); + assert.match(source, /if \(actor === 'pirate'\) shot\.setData\('playerHitQueued', false\)/); + assert.match(source, /shot !== this\.player[\s\S]*?projectileOwner'\) === 'pirate'[\s\S]*?shot\.destroy\(\)/); + assert.match(source, /if \(shot\.getData\('playerHitQueued'\)\) return/); +}); + + +test('live player validation reports invalid state without repairing gameplay state', async () => { + const source = await read('src/game-v4.js'); + + assert.match(source, /this\.processPlayerHits\(\);\s*this\.validateLivePlayerState\(\)/); + const match = /validateLivePlayerState\(\) \{([\s\S]*?)\n buildHudState\(/.exec(source); + assert.ok(match, 'validateLivePlayerState block should be found'); + + const block = match[1]; + assert.match(block, /player:live-state-invalid/); + assert.match(block, /issues\.push\('body-disabled'\)/); + assert.doesNotMatch(block, /setActive\(/); + assert.doesNotMatch(block, /setVisible\(/); + assert.doesNotMatch(block, /setAlpha\(/); + assert.doesNotMatch(block, /body\.enable\s*=/); +}); + + +test('player overlap resolves an explicitly tagged pirate projectile before queueing or destroying it', async () => { + const source = await read('src/game-v4.js'); + + assert.match(source, /shot\.setData\('projectileOwner', actor\)/); + assert.match(source, /hitPlayer\(first, second\)/); + assert.match(source, /resolveTaggedProjectile\(first, second, 'pirate'\)/); + assert.match(source, /if \(!shot\?\.active \|\| shot === this\.player\)/); + assert.match(source, /shot !== this\.player[\s\S]*?projectileOwner'\) === 'pirate'[\s\S]*?shot\.destroy\(\)/); +}); + + +test('health frame positioning is applied to the shared art layer', async () => { + const source = await read('src/mobile-hud.js'); + + assert.match(source, /const healthFrame = \(current, max\) =>/); + assert.match(source, /const setHealthFrame = \(piece, frame\) =>/); + assert.match(source, /piece\.querySelector\('\.steam-health-art'\)/); + assert.match(source, /art\.style\.backgroundPosition/); + assert.match(source, /setHealthFrame\(\s*playerHealthPiece,/); + assert.match(source, /setHealthFrame\(\s*enemyHealthPiece,/); +}); 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); +}); diff --git a/tmp/mcp-import-smoke.txt b/tmp/mcp-import-smoke.txt new file mode 100644 index 0000000..3b09696 --- /dev/null +++ b/tmp/mcp-import-smoke.txt @@ -0,0 +1 @@ +arrowstack staged import smoke test: update path verified