Complete airship-phaser mirror
This commit is contained in:
parent
9c8f7b3e60
commit
580994daee
262
src/world/world-generator.js
Normal file
262
src/world/world-generator.js
Normal file
@ -0,0 +1,262 @@
|
|||||||
|
import { createSeededRandom, randomBetween, randomInt } from './seeded-random.js';
|
||||||
|
|
||||||
|
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
|
||||||
|
const distance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
|
||||||
|
|
||||||
|
const pointSegmentDistance = (point, start, end) => {
|
||||||
|
const dx = end.x - start.x;
|
||||||
|
const dy = end.y - start.y;
|
||||||
|
const lengthSquared = dx * dx + dy * dy;
|
||||||
|
if (lengthSquared <= 0) return distance(point, start);
|
||||||
|
const t = clamp(((point.x - start.x) * dx + (point.y - start.y) * dy) / lengthSquared, 0, 1);
|
||||||
|
return Math.hypot(point.x - (start.x + dx * t), point.y - (start.y + dy * t));
|
||||||
|
};
|
||||||
|
|
||||||
|
const nearestPortDistance = (island, ports) => ports.length
|
||||||
|
? Math.min(...ports.map((port) => distance(island, port)))
|
||||||
|
: Infinity;
|
||||||
|
|
||||||
|
const createRegions = (random, width, height) => {
|
||||||
|
const types = ['trade', 'settled', 'frontier', 'pirate', 'weather', 'frontier'];
|
||||||
|
return types.map((type, index) => ({
|
||||||
|
id: `region-${index + 1}`,
|
||||||
|
type,
|
||||||
|
x: randomBetween(random, width * 0.15, width * 0.85),
|
||||||
|
y: randomBetween(random, height * 0.15, height * 0.85),
|
||||||
|
radius: randomBetween(random, width * 0.18, width * 0.32),
|
||||||
|
wealth: type === 'trade' ? randomBetween(random, 0.72, 0.96) : randomBetween(random, 0.25, 0.78),
|
||||||
|
security: type === 'settled' ? randomBetween(random, 0.72, 0.95)
|
||||||
|
: type === 'pirate' ? randomBetween(random, 0.05, 0.25)
|
||||||
|
: randomBetween(random, 0.28, 0.68),
|
||||||
|
weatherSeverity: type === 'weather' ? randomBetween(random, 0.72, 0.98) : randomBetween(random, 0.12, 0.7),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateIslands = (random, width, height, count) => {
|
||||||
|
const islands = [];
|
||||||
|
const margin = Math.min(width, height) * 0.07;
|
||||||
|
const minSpacing = Math.min(width, height) * 0.085;
|
||||||
|
|
||||||
|
for (let attempt = 0; islands.length < count && attempt < count * 80; attempt += 1) {
|
||||||
|
const radius = randomBetween(random, 68, 148);
|
||||||
|
const candidate = {
|
||||||
|
id: `island-${islands.length + 1}`,
|
||||||
|
x: randomBetween(random, margin + radius, width - margin - radius),
|
||||||
|
y: randomBetween(random, margin + radius, height - margin - radius),
|
||||||
|
radius,
|
||||||
|
turbulenceRadius: radius * randomBetween(random, 2.2, 2.9),
|
||||||
|
settlementRoll: random(),
|
||||||
|
resource: ['timber', 'ore', 'food', 'fuel'][randomInt(random, 0, 3)],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (islands.every((other) => distance(candidate, other) >= minSpacing + radius + other.radius * 0.35)) {
|
||||||
|
islands.push(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return islands;
|
||||||
|
};
|
||||||
|
|
||||||
|
const generatePorts = (random, islands, count) => {
|
||||||
|
const selected = [...islands]
|
||||||
|
.sort((a, b) => b.settlementRoll - a.settlementRoll)
|
||||||
|
.slice(0, Math.min(count, islands.length));
|
||||||
|
|
||||||
|
return selected.map((island, index) => {
|
||||||
|
const portType = index === 0 ? 'capital'
|
||||||
|
: island.resource === 'ore' ? 'industrial'
|
||||||
|
: island.resource === 'fuel' ? 'fuel'
|
||||||
|
: index % 3 === 0 ? 'military'
|
||||||
|
: 'trade';
|
||||||
|
const baseSecurity = portType === 'capital' ? 0.92
|
||||||
|
: portType === 'military' ? 0.82
|
||||||
|
: randomBetween(random, 0.42, 0.76);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `port-${index + 1}`,
|
||||||
|
islandId: island.id,
|
||||||
|
name: index === 0 ? 'Aerie Harbour' : `Skyport ${index + 1}`,
|
||||||
|
x: island.x,
|
||||||
|
y: island.y,
|
||||||
|
type: portType,
|
||||||
|
wealth: portType === 'capital' ? 0.9 : randomBetween(random, 0.38, 0.88),
|
||||||
|
security: baseSecurity,
|
||||||
|
exports: [island.resource],
|
||||||
|
imports: island.resource === 'food' ? ['ore'] : ['food'],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const routeKey = (a, b) => [a.id, b.id].sort().join(':');
|
||||||
|
|
||||||
|
const generateTradeRoutes = (random, ports) => {
|
||||||
|
if (ports.length < 2) return [];
|
||||||
|
const routes = [];
|
||||||
|
const connected = new Set([ports[0].id]);
|
||||||
|
const used = new Set();
|
||||||
|
|
||||||
|
while (connected.size < ports.length) {
|
||||||
|
let best = null;
|
||||||
|
for (const from of ports) {
|
||||||
|
if (!connected.has(from.id)) continue;
|
||||||
|
for (const to of ports) {
|
||||||
|
if (connected.has(to.id)) continue;
|
||||||
|
const cost = distance(from, to);
|
||||||
|
if (!best || cost < best.cost) best = { from, to, cost };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!best) break;
|
||||||
|
connected.add(best.to.id);
|
||||||
|
used.add(routeKey(best.from, best.to));
|
||||||
|
routes.push({ from: best.from, to: best.to });
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = [];
|
||||||
|
for (let i = 0; i < ports.length; i += 1) {
|
||||||
|
for (let j = i + 1; j < ports.length; j += 1) {
|
||||||
|
if (used.has(routeKey(ports[i], ports[j]))) continue;
|
||||||
|
candidates.push({ from: ports[i], to: ports[j], cost: distance(ports[i], ports[j]) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.sort((a, b) => a.cost - b.cost);
|
||||||
|
for (const candidate of candidates.slice(0, Math.max(1, Math.floor(ports.length / 3)))) {
|
||||||
|
if (random() < 0.72) routes.push(candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return routes.map(({ from, to }, index) => {
|
||||||
|
const traffic = clamp((from.wealth + to.wealth) / 2 + randomBetween(random, -0.08, 0.08), 0.2, 1);
|
||||||
|
const security = clamp((from.security + to.security) / 2 + randomBetween(random, -0.12, 0.06), 0.1, 0.95);
|
||||||
|
return {
|
||||||
|
id: `route-${index + 1}`,
|
||||||
|
fromPortId: from.id,
|
||||||
|
toPortId: to.id,
|
||||||
|
from: { x: from.x, y: from.y },
|
||||||
|
to: { x: to.x, y: to.y },
|
||||||
|
traffic,
|
||||||
|
value: clamp(traffic * ((from.wealth + to.wealth) / 2), 0.1, 1),
|
||||||
|
security,
|
||||||
|
width: randomBetween(random, 150, 280),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const generatePirateTerritories = (random, islands, ports, routes, count = 3) => {
|
||||||
|
if (!islands.length || !routes.length) return [];
|
||||||
|
|
||||||
|
const candidates = islands
|
||||||
|
.filter((island) => !ports.some((port) => port.islandId === island.id))
|
||||||
|
.map((island) => {
|
||||||
|
let bestRoute = null;
|
||||||
|
let routeDistance = Infinity;
|
||||||
|
for (const route of routes) {
|
||||||
|
const candidateDistance = pointSegmentDistance(island, route.from, route.to);
|
||||||
|
if (candidateDistance < routeDistance) {
|
||||||
|
routeDistance = candidateDistance;
|
||||||
|
bestRoute = route;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const remoteness = clamp(nearestPortDistance(island, ports) / 900, 0, 1);
|
||||||
|
const opportunity = bestRoute ? bestRoute.value * (1 - bestRoute.security * 0.55) : 0;
|
||||||
|
const proximity = clamp(1 - routeDistance / 1000, 0, 1);
|
||||||
|
return {
|
||||||
|
island,
|
||||||
|
route: bestRoute,
|
||||||
|
score: opportunity * 0.52 + proximity * 0.28 + remoteness * 0.2 + randomBetween(random, 0, 0.08),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, Math.min(count, Math.max(1, candidatesLength(islands, ports))));
|
||||||
|
|
||||||
|
return candidates.map(({ island, route, score }, index) => ({
|
||||||
|
id: `pirate-territory-${index + 1}`,
|
||||||
|
name: ['Blackwake Reach', 'Corsair Belt', 'Red Lantern Expanse'][index] || `Pirate Reach ${index + 1}`,
|
||||||
|
center: { x: island.x, y: island.y },
|
||||||
|
radius: randomBetween(random, 460, 760),
|
||||||
|
strength: clamp(0.45 + score * 0.55, 0.45, 1),
|
||||||
|
aggression: randomBetween(random, 0.62, 0.95),
|
||||||
|
havenIslandId: island.id,
|
||||||
|
targetRouteId: route?.id || null,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const candidatesLength = (islands, ports) => Math.max(
|
||||||
|
0,
|
||||||
|
islands.length - new Set(ports.map((port) => port.islandId)).size,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const generateWorld = ({
|
||||||
|
seed = 'SKYWARD-001',
|
||||||
|
width = 3600,
|
||||||
|
height = 3600,
|
||||||
|
islandCount = 16,
|
||||||
|
portCount = 7,
|
||||||
|
} = {}) => {
|
||||||
|
const random = createSeededRandom(seed);
|
||||||
|
const regions = createRegions(random, width, height);
|
||||||
|
const islands = generateIslands(random, width, height, islandCount);
|
||||||
|
const ports = generatePorts(random, islands, portCount);
|
||||||
|
const tradeRoutes = generateTradeRoutes(random, ports);
|
||||||
|
const pirateTerritories = generatePirateTerritories(random, islands, ports, tradeRoutes, 3);
|
||||||
|
const starterPort = [...ports].sort((a, b) => b.security - a.security)[0]
|
||||||
|
|| { x: width / 2, y: height / 2 };
|
||||||
|
|
||||||
|
return {
|
||||||
|
seed,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
regions,
|
||||||
|
islands,
|
||||||
|
ports,
|
||||||
|
tradeRoutes,
|
||||||
|
pirateTerritories,
|
||||||
|
spawn: {
|
||||||
|
x: clamp(starterPort.x + 120, 120, width - 120),
|
||||||
|
y: clamp(starterPort.y + 150, 120, height - 120),
|
||||||
|
portId: starterPort.id || null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pirateInfluenceAt = (world, x, y) => {
|
||||||
|
let influence = 0;
|
||||||
|
for (const territory of world?.pirateTerritories || []) {
|
||||||
|
const dist = Math.hypot(x - territory.center.x, y - territory.center.y);
|
||||||
|
if (dist >= territory.radius) continue;
|
||||||
|
influence = Math.max(influence, (1 - dist / territory.radius) * territory.strength);
|
||||||
|
}
|
||||||
|
return clamp(influence, 0, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const nearestPort = (world, x, y) => {
|
||||||
|
let result = null;
|
||||||
|
let range = Infinity;
|
||||||
|
for (const port of world?.ports || []) {
|
||||||
|
const candidate = Math.hypot(x - port.x, y - port.y);
|
||||||
|
if (candidate < range) {
|
||||||
|
result = port;
|
||||||
|
range = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { port: result, range };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const pirateSpawnPointsForWave = (world, player, count, wave = 1) => {
|
||||||
|
const territories = [...(world?.pirateTerritories || [])]
|
||||||
|
.sort((a, b) => distance(a.center, player) - distance(b.center, player));
|
||||||
|
const territory = territories[(Math.max(1, wave) - 1) % Math.max(1, territories.length)];
|
||||||
|
const center = territory?.center || player;
|
||||||
|
const radius = territory ? Math.min(territory.radius * 0.62, 520) : 520;
|
||||||
|
const random = createSeededRandom(`${world?.seed || 'world'}:wave:${wave}`);
|
||||||
|
|
||||||
|
return Array.from({ length: count }, (_, index) => {
|
||||||
|
const angle = (Math.PI * 2 * index / Math.max(1, count)) + randomBetween(random, -0.28, 0.28);
|
||||||
|
const spread = radius * randomBetween(random, 0.62, 1);
|
||||||
|
return {
|
||||||
|
x: clamp(center.x + Math.cos(angle) * spread, 120, (world?.width || 3600) - 120),
|
||||||
|
y: clamp(center.y + Math.sin(angle) * spread, 120, (world?.height || 3600) - 120),
|
||||||
|
angle,
|
||||||
|
territoryId: territory?.id || null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
117
tests/assets.test.mjs
Normal file
117
tests/assets.test.mjs
Normal 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`);
|
||||||
|
});
|
||||||
49
tests/diagnostics.test.mjs
Normal file
49
tests/diagnostics.test.mjs
Normal 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);
|
||||||
|
});
|
||||||
82
tests/environment-physics.test.mjs
Normal file
82
tests/environment-physics.test.mjs
Normal 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
117
tests/game-logic.test.mjs
Normal 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
103
tests/pirate-ai.test.mjs
Normal 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);
|
||||||
|
});
|
||||||
55
tests/progression.test.mjs
Normal file
55
tests/progression.test.mjs
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
bestAffordableMarketUpgrade,
|
||||||
|
buyAndInstallModule,
|
||||||
|
collectSalvage,
|
||||||
|
createStarterProgression,
|
||||||
|
deriveShipStats,
|
||||||
|
installModule,
|
||||||
|
salvageForPirate,
|
||||||
|
} from '../src/progression/ship-loadout.js';
|
||||||
|
|
||||||
|
test('starter loadout preserves current ship combat and handling values', () => {
|
||||||
|
const progression = createStarterProgression();
|
||||||
|
const stats = deriveShipStats(progression.loadout);
|
||||||
|
assert.equal(stats.maxHull, 100);
|
||||||
|
assert.equal(stats.maxSpeed, 180);
|
||||||
|
assert.equal(stats.turnRate, 76);
|
||||||
|
assert.equal(stats.reloadMs, 850);
|
||||||
|
assert.equal(stats.cannonDamage, 16);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('installed modules derive meaningful ship stat changes', () => {
|
||||||
|
const progression = installModule(createStarterProgression(), 'corsair-overdrive');
|
||||||
|
const stats = deriveShipStats(progression.loadout);
|
||||||
|
assert.equal(stats.maxSpeed, 214);
|
||||||
|
assert.equal(stats.acceleration, 110);
|
||||||
|
assert.equal(stats.turnRate, 71);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('port purchase deducts credits and installs the module', () => {
|
||||||
|
const starter = createStarterProgression();
|
||||||
|
const result = buyAndInstallModule(starter, 'tuned-propellers');
|
||||||
|
assert.equal(result.purchased, true);
|
||||||
|
assert.equal(result.credits, starter.credits - 280);
|
||||||
|
assert.equal(result.loadout.engine, 'tuned-propellers');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pirate salvage is deterministic and collectible within salvage capacity', () => {
|
||||||
|
const salvageA = salvageForPirate({ seed: 'WORLD', classKey: 'captain', wave: 4, sequence: 2 });
|
||||||
|
const salvageB = salvageForPirate({ seed: 'WORLD', classKey: 'captain', wave: 4, sequence: 2 });
|
||||||
|
assert.deepEqual(salvageA, salvageB);
|
||||||
|
|
||||||
|
const starter = createStarterProgression();
|
||||||
|
const collected = collectSalvage(starter, salvageA);
|
||||||
|
assert.ok(collected.credits >= starter.credits);
|
||||||
|
assert.ok(collected.scrap >= starter.scrap);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('market can select an affordable upgrade for a port', () => {
|
||||||
|
const starter = createStarterProgression();
|
||||||
|
const upgrade = bestAffordableMarketUpgrade(starter, { type: 'military' });
|
||||||
|
assert.ok(upgrade);
|
||||||
|
assert.ok(upgrade.price <= starter.credits);
|
||||||
|
});
|
||||||
414
tests/wiring.test.mjs
Normal file
414
tests/wiring.test.mjs
Normal 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,/);
|
||||||
|
});
|
||||||
61
tests/world-generator.test.mjs
Normal file
61
tests/world-generator.test.mjs
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
generateWorld,
|
||||||
|
nearestPort,
|
||||||
|
pirateInfluenceAt,
|
||||||
|
pirateSpawnPointsForWave,
|
||||||
|
} from '../src/world/world-generator.js';
|
||||||
|
|
||||||
|
test('world generation is deterministic for a seed', () => {
|
||||||
|
const a = generateWorld({ seed: 'TEST-SEED', width: 3600, height: 3600 });
|
||||||
|
const b = generateWorld({ seed: 'TEST-SEED', width: 3600, height: 3600 });
|
||||||
|
assert.deepEqual(a, b);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('different seeds create different geography', () => {
|
||||||
|
const a = generateWorld({ seed: 'A' });
|
||||||
|
const b = generateWorld({ seed: 'B' });
|
||||||
|
assert.notDeepEqual(a.islands, b.islands);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generated world contains ports, trade routes, and pirate territory', () => {
|
||||||
|
const world = generateWorld({ seed: 'TRADE-WORLD' });
|
||||||
|
assert.ok(world.islands.length >= 10);
|
||||||
|
assert.ok(world.ports.length >= 4);
|
||||||
|
assert.ok(world.tradeRoutes.length >= world.ports.length - 1);
|
||||||
|
assert.ok(world.pirateTerritories.length >= 1);
|
||||||
|
|
||||||
|
const portIds = new Set(world.ports.map(({ id }) => id));
|
||||||
|
for (const route of world.tradeRoutes) {
|
||||||
|
assert.ok(portIds.has(route.fromPortId));
|
||||||
|
assert.ok(portIds.has(route.toPortId));
|
||||||
|
assert.ok(route.value > 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pirate influence peaks inside generated pirate territory', () => {
|
||||||
|
const world = generateWorld({ seed: 'BLACKWAKE' });
|
||||||
|
const territory = world.pirateTerritories[0];
|
||||||
|
assert.ok(territory);
|
||||||
|
assert.ok(pirateInfluenceAt(world, territory.center.x, territory.center.y) >= 0.4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wave spawns originate around generated pirate territory and remain in bounds', () => {
|
||||||
|
const world = generateWorld({ seed: 'SPAWN-WORLD' });
|
||||||
|
const player = { x: world.spawn.x, y: world.spawn.y };
|
||||||
|
const points = pirateSpawnPointsForWave(world, player, 5, 3);
|
||||||
|
assert.equal(points.length, 5);
|
||||||
|
for (const point of points) {
|
||||||
|
assert.ok(point.x >= 120 && point.x <= world.width - 120);
|
||||||
|
assert.ok(point.y >= 120 && point.y <= world.height - 120);
|
||||||
|
assert.ok(point.territoryId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nearestPort reports the generated starter port nearby', () => {
|
||||||
|
const world = generateWorld({ seed: 'PORT-WORLD' });
|
||||||
|
const result = nearestPort(world, world.spawn.x, world.spawn.y);
|
||||||
|
assert.ok(result.port);
|
||||||
|
assert.ok(result.range < 400);
|
||||||
|
});
|
||||||
1
tmp/mcp-import-smoke.txt
Normal file
1
tmp/mcp-import-smoke.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
arrowstack staged import smoke test: update path verified
|
||||||
Loading…
x
Reference in New Issue
Block a user