Complete airship-phaser mirror

This commit is contained in:
Admin 2026-09-20 08:36:39 +00:00
parent 9c8f7b3e60
commit 580994daee
10 changed files with 1261 additions and 0 deletions

View File

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

117
tests/assets.test.mjs Normal file
View File

@ -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`);
});

View File

@ -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);
});

View File

@ -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);
});

117
tests/game-logic.test.mjs Normal file
View File

@ -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);
});

103
tests/pirate-ai.test.mjs Normal file
View File

@ -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);
});

View File

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

414
tests/wiring.test.mjs Normal file
View File

@ -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,/);
});

View File

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

1
tmp/mcp-import-smoke.txt Normal file
View File

@ -0,0 +1 @@
arrowstack staged import smoke test: update path verified