Tighten combat AI and HUD state flow

This commit is contained in:
Admin 2026-09-19 01:24:49 +00:00
parent 79f98ce568
commit 9adf91162a
6 changed files with 333 additions and 364 deletions

View File

@ -1,6 +1,5 @@
export const PLAYER_MAX_HULL = 100;
export const DEFAULT_PIRATE_SHOT_DAMAGE = 6;
export const PLAYER_HIT_COOLDOWN_MS = 300;
export const normalizePlayerDamage = (
rawDamage,
@ -11,107 +10,6 @@ export const normalizePlayerDamage = (
return Math.max(1, Math.min(25, value));
};
export const reducePlayerHitQueue = ({
hull,
lastHitAt,
now,
damages,
cooldownMs = PLAYER_HIT_COOLDOWN_MS,
maxHull = PLAYER_MAX_HULL,
}) => {
let nextHull = Number.isFinite(hull) ? hull : maxHull;
let nextLastHitAt = Number.isFinite(lastHitAt) ? lastHitAt : -Infinity;
let appliedHits = 0;
let ignoredHits = 0;
let totalDamage = 0;
for (const rawDamage of damages) {
if (now - nextLastHitAt < cooldownMs) {
ignoredHits += 1;
continue;
}
const damage = normalizePlayerDamage(rawDamage);
nextLastHitAt = now;
nextHull = Math.max(0, nextHull - damage);
totalDamage += damage;
appliedHits += 1;
if (nextHull <= 0) break;
}
return {
hull: nextHull,
lastHitAt: nextLastHitAt,
appliedHits,
ignoredHits,
totalDamage,
gameOver: nextHull <= 0,
};
};
export const processQueuedPlayerHits = ({
hull,
lastHitAt,
now,
hits,
cooldownMs = PLAYER_HIT_COOLDOWN_MS,
maxHull = PLAYER_MAX_HULL,
}) => {
return reducePlayerHitQueue({
hull,
lastHitAt,
now,
damages: hits.map(({ damage }) => damage),
cooldownMs,
maxHull,
});
};
export const reducePlayerVolleyHits = ({
hull,
hits,
seenVolleyIds = [],
maxHull = PLAYER_MAX_HULL,
fallbackDamage = DEFAULT_PIRATE_SHOT_DAMAGE,
maxSeenVolleys = 128,
}) => {
const seen = new Set(seenVolleyIds);
const acceptedHits = [];
const ignoredHits = [];
let totalDamage = 0;
for (const hit of hits) {
const volleyId = hit?.volleyId ?? null;
if (volleyId !== null && seen.has(volleyId)) {
ignoredHits.push(hit);
continue;
}
if (volleyId !== null) seen.add(volleyId);
acceptedHits.push(hit);
totalDamage += normalizePlayerDamage(hit?.damage, fallbackDamage);
}
while (seen.size > maxSeenVolleys) {
const oldest = seen.values().next().value;
seen.delete(oldest);
}
const currentHull = Number.isFinite(hull) ? hull : maxHull;
const nextHull = Math.max(0, currentHull - totalDamage);
return {
hull: nextHull,
totalDamage,
acceptedHits,
ignoredHits,
seenVolleyIds: seen,
gameOver: nextHull <= 0,
};
};
export const reducePlayerProjectileHits = ({
hull,
hits,
@ -133,7 +31,6 @@ export const reducePlayerProjectileHits = ({
};
};
export const resolveTaggedProjectile = (first, second, owner) => {
for (const candidate of [first, second]) {
if (!candidate || typeof candidate.getData !== 'function') continue;

View File

@ -3,8 +3,19 @@ import './style.css';
import airshipSheetUrl from './assets/airships.svg?url';
import explosionSheetUrl from './assets/explosions.svg?url';
import { airshipLog, registerDebugSnapshot } from './diagnostics.js';
import { normalizePlayerDamage, reducePlayerProjectileHits, resolveTaggedProjectile } from './game-logic.js';
import { pirateClassForWave, separationVector } from './pirate-ai.js';
import {
DEFAULT_DEFAULT_PIRATE_SHOT_DAMAGE,
PLAYER_MAX_HULL,
normalizePlayerDamage,
reducePlayerProjectileHits,
resolveTaggedProjectile,
} from './game-logic.js';
import {
pirateDesiredHeading,
pirateRosterForWave,
pirateSpeedForRange,
separationVector,
} from './pirate-ai.js';
const WORLD_SIZE = 2400;
const PLAYER_MAX_SPEED = 180;
@ -18,10 +29,9 @@ const HELM_STEER_CURVE = 1.18;
const PLAYER_BROADSIDE_RELOAD_MS = 850;
const SHOT_SPEED = 520;
const SHOT_LIFETIME = 900;
const PLAYER_MAX_HULL = 100;
const PIRATE_FALLBACK_MAX_HULL = 120;
const PIRATE_SHOT_DAMAGE = 6;
const MAX_PIRATES = 6;
const HUD_PUBLISH_INTERVAL_MS = 50;
const AUTO_RESET_MS = 2200;
const clamp = (v, min, max) => Math.max(min, Math.min(max, v));
@ -51,6 +61,7 @@ class GameScene extends Phaser.Scene {
this.pendingPlayerHits = [];
this.playerHitSequence = 0;
this.lastDiagnosticsAt = -Infinity;
this.lastHudPublishAt = -Infinity;
this.wave = 1;
this.defeated = 0;
@ -267,8 +278,13 @@ class GameScene extends Phaser.Scene {
}
spawnWave() {
const count = Math.min(this.wave, MAX_PIRATES);
airshipLog('wave:spawn', { wave: this.wave, count });
const roster = pirateRosterForWave(this.wave, MAX_PIRATES);
const count = roster.length;
airshipLog('wave:spawn', {
wave: this.wave,
count,
classes: roster.map(({ key }) => key),
});
this.status.setText(`WAVE ${this.wave}\n${count} PIRATE${count === 1 ? '' : 'S'}`);
for (let i = 0; i < count; i++) {
@ -276,7 +292,7 @@ class GameScene extends Phaser.Scene {
const radius = Phaser.Math.Between(500, 760);
const x = clamp(this.player.x + Math.cos(angle) * radius, 120, WORLD_SIZE - 120);
const y = clamp(this.player.y + Math.sin(angle) * radius, 120, WORLD_SIZE - 120);
this.spawnPirate(x, y, angle, pirateClassForWave(this.wave, i, count));
this.spawnPirate(x, y, angle, roster[i]);
}
this.time.delayedCall(900, () => {
@ -297,7 +313,7 @@ class GameScene extends Phaser.Scene {
pirate.setData('speed', pirateClass.speed);
pirate.setData('turnRate', pirateClass.turnRate);
pirate.setData('reloadMs', pirateClass.reloadMs);
pirate.setData('damage', pirateClass.damage);
pirate.setData('projectileDamage', pirateClass.projectileDamage);
pirate.setData('preferredRange', pirateClass.preferredRange);
pirate.setData('retreatRange', pirateClass.retreatRange);
pirate.setData('broadsideRange', pirateClass.broadsideRange);
@ -367,16 +383,18 @@ class GameScene extends Phaser.Scene {
if (this.gameState !== 'playing') {
if (Phaser.Input.Keyboard.JustDown(this.keys.restart)) this.scene.restart();
this.drawHud();
this.publishHudState();
const hudState = this.buildHudState();
if (!this.mobileHudActive) this.drawHud(hudState);
this.publishHudState(hudState, true);
return;
}
this.processPlayerHits();
this.ensureLivePlayerState();
this.validateLivePlayerState();
if (this.gameState !== 'playing') {
this.drawHud();
this.publishHudState();
const hudState = this.buildHudState();
if (!this.mobileHudActive) this.drawHud(hudState);
this.publishHudState(hudState, true);
return;
}
@ -398,9 +416,11 @@ class GameScene extends Phaser.Scene {
this.updatePirates(dt);
this.updateProjectiles();
this.drawWeaponArcs();
this.drawHud();
this.checkGameState();
this.publishHudState();
const hudState = this.buildHudState();
if (!this.mobileHudActive) this.drawHud(hudState);
this.publishHudState(hudState, hadQueuedPlayerHit);
if (hadQueuedPlayerHit) {
airshipLog('combat:post-hit-frame-complete', {
@ -463,12 +483,13 @@ class GameScene extends Phaser.Scene {
const turnRate = pirate.getData('turnRate') || 52;
const baseSpeed = pirate.getData('speed') || 118;
let desiredHeading = bearing;
if (distance < preferredRange + 40) {
const optionA = normalizeDegrees(bearing + 90);
const optionB = normalizeDegrees(bearing - 90);
desiredHeading = Math.abs(signedAngleDelta(optionA, pirate.angle)) < Math.abs(signedAngleDelta(optionB, pirate.angle)) ? optionA : optionB;
}
let desiredHeading = pirateDesiredHeading({
bearing,
currentHeading: pirate.angle,
distance,
preferredRange,
retreatRange,
});
const separation = separationVector(pirate, pirates);
if (separation.x || separation.y) {
@ -481,11 +502,12 @@ class GameScene extends Phaser.Scene {
const delta = signedAngleDelta(desiredHeading, pirate.angle);
pirate.angle += clamp(delta, -turnRate * dt, turnRate * dt);
const speed = distance > preferredRange + 100
? baseSpeed
: distance < retreatRange
? baseSpeed * 0.58
: baseSpeed * 0.82;
const speed = pirateSpeedForRange({
distance,
preferredRange,
retreatRange,
baseSpeed,
});
const h = headingRad(pirate);
pirate.setVelocity(Math.cos(h) * speed, Math.sin(h) * speed);
@ -495,11 +517,11 @@ class GameScene extends Phaser.Scene {
const broadsideRange = pirate.getData('broadsideRange') || 420;
const broadsideTolerance = pirate.getData('broadsideError') || 38;
const reloadMs = pirate.getData('reloadMs') || 1650;
const damage = pirate.getData('damage') || PIRATE_SHOT_DAMAGE;
const projectileDamage = pirate.getData('projectileDamage') || DEFAULT_PIRATE_SHOT_DAMAGE;
if (distance < broadsideRange && broadsideError < broadsideTolerance && this.time.now - lastShot > reloadMs) {
const side = relative >= 0 ? 1 : -1;
this.fireBroadside(pirate, side, this.pirateShots, 'pirate-shot', damage, 0.9);
this.fireBroadside(pirate, side, this.pirateShots, 'pirate-shot', projectileDamage, 0.9);
pirate.setData('lastShot', this.time.now);
}
}
@ -507,12 +529,12 @@ class GameScene extends Phaser.Scene {
playerBroadside(side) {
const broadside = side < 0 ? 'port' : 'starboard';
if (this.time.now - this.lastPlayerShot[broadside] < PLAYER_BROADSIDE_RELOAD_MS) return;
if (this.time.now - this.lastPlayerShot[broadside] < this.playerBroadsideReloadMs) return;
this.fireBroadside(this.player, side, this.playerShots, 'player-shot', 16, 1.0);
this.lastPlayerShot[broadside] = this.time.now;
}
fireBroadside(ship, side, group, texture, damage, velocityScale) {
fireBroadside(ship, side, group, texture, projectileDamage, velocityScale) {
if (!ship?.body) return;
const actor = group === this.pirateShots ? 'pirate' : 'player';
const base = headingRad(ship) + side * Math.PI / 2;
@ -520,7 +542,7 @@ class GameScene extends Phaser.Scene {
airshipLog('combat:broadside-fired', {
actor,
side: side < 0 ? 'port' : 'starboard',
damage,
damage: projectileDamage,
x: Math.round(ship.x),
y: Math.round(ship.y),
angle: Math.round(ship.angle),
@ -535,7 +557,7 @@ class GameScene extends Phaser.Scene {
shot.setDepth(8);
shot.body.setAllowGravity(false);
shot.setData('bornAt', this.time.now);
shot.setData('damage', damage);
shot.setData('damage', projectileDamage);
shot.setData('projectileOwner', actor);
if (actor === 'pirate') shot.setData('playerHitQueued', false);
shot.setVelocity(
@ -602,7 +624,7 @@ class GameScene extends Phaser.Scene {
if (shot.getData('playerHitQueued')) return;
const damage = normalizePlayerDamage(shot.getData('damage'), PIRATE_SHOT_DAMAGE);
const damage = normalizePlayerDamage(shot.getData('damage'), DEFAULT_PIRATE_SHOT_DAMAGE);
// The overlap callback only records a verified pirate projectile.
// Hull mutation and projectile destruction happen on the next scene update,
@ -643,7 +665,7 @@ class GameScene extends Phaser.Scene {
hull: this.playerHull,
hits,
maxHull: PLAYER_MAX_HULL,
fallbackDamage: PIRATE_SHOT_DAMAGE,
fallbackDamage: DEFAULT_PIRATE_SHOT_DAMAGE,
});
this.playerHull = result.hull;
@ -714,7 +736,7 @@ class GameScene extends Phaser.Scene {
});
}
playPlayerHitReaction(damage = PIRATE_SHOT_DAMAGE) {
playPlayerHitReaction(damage = DEFAULT_PIRATE_SHOT_DAMAGE) {
if (!this.player?.active) return;
this.player.setTint(0xffd7d7);
@ -725,15 +747,25 @@ class GameScene extends Phaser.Scene {
this.cameras.main.shake(120, clamp(0.0035 + damage * 0.00055, 0.004, 0.012));
}
ensureLivePlayerState() {
validateLivePlayerState() {
if (this.gameState !== 'playing' || this.playerHull <= 0 || !this.player) return;
if (!this.player.active) this.player.setActive(true);
if (!this.player.visible) this.player.setVisible(true);
if (this.player.alpha !== 1) this.player.setAlpha(1);
const issues = [];
if (!this.player.active) issues.push('inactive');
if (!this.player.visible) issues.push('hidden');
if (this.player.alpha !== 1) issues.push('alpha');
if (!this.player.body) issues.push('body-missing');
else if (!this.player.body.enable) issues.push('body-disabled');
if (this.player.body && !this.player.body.enable) {
this.player.body.enable = true;
if (issues.length > 0) {
airshipLog('player:live-state-invalid', {
issues,
hull: this.playerHull,
active: this.player.active,
visible: this.player.visible,
alpha: this.player.alpha,
bodyEnabled: this.player.body?.enable ?? null,
}, 'warn');
}
}
@ -745,7 +777,7 @@ class GameScene extends Phaser.Scene {
const { pirate: nearest, range } = this.nearestPirate();
const now = this.time?.now ?? 0;
const reloadReady = (side) => clamp(
(now - (this.lastPlayerShot?.[side] ?? -Infinity)) / this.playerBroadsideReloadMs,
(now - (this.lastPlayerShot?.[side] ?? -Infinity)) / Math.max(1, this.playerBroadsideReloadMs),
0,
1,
);
@ -780,9 +812,13 @@ class GameScene extends Phaser.Scene {
};
}
publishHudState() {
const state = this.buildHudState();
if (!state) return;
publishHudState(state = this.buildHudState(), force = false) {
if (!this.mobileHudActive || !state) return;
const now = this.time?.now ?? 0;
if (!force && now - this.lastHudPublishAt < HUD_PUBLISH_INTERVAL_MS) return;
this.lastHudPublishAt = now;
window.dispatchEvent(new CustomEvent('airship:hud-state', { detail: state }));
}
@ -825,28 +861,30 @@ class GameScene extends Phaser.Scene {
return { pirate: nearest, range: nearestRange };
}
drawHud() {
drawHud(state = this.buildHudState()) {
if (!state) return;
const width = this.scale.width;
const compact = width < 700;
const heading = normalizeDegrees(this.player.angle);
const activePirates = this.pirates?.countActive(true) || 0;
const { pirate: nearest, range } = this.nearestPirate();
const nearestHull = nearest ? (nearest.getData('hull') || 0) : 0;
const nearestMaxHull = nearest ? (nearest.getData('maxHull') || PIRATE_FALLBACK_MAX_HULL) : PIRATE_FALLBACK_MAX_HULL;
const nearestClass = nearest ? (nearest.getData('classLabel') || 'PIRATE') : 'PIRATE';
const rangeText = Number.isFinite(range) ? `${range.toFixed(0)}m` : '--';
const target = state.target;
const playerHull = state.player?.hull ?? PLAYER_MAX_HULL;
const playerMaxHull = state.player?.maxHull ?? PLAYER_MAX_HULL;
const nearestHull = target?.hull ?? 0;
const nearestMaxHull = target?.maxHull ?? PIRATE_FALLBACK_MAX_HULL;
const nearestClass = target?.name || 'PIRATE';
const rangeText = target && Number.isFinite(target.range) ? `${target.range.toFixed(0)}m` : '--';
this.speedText.setText(`${Math.abs(this.playerSpeed).toFixed(0)} ${this.playerSpeed < 0 ? 'REV' : 'FWD'}`);
this.headingText.setText(`${heading.toFixed(0).padStart(3, '0')}°`);
this.waveText.setText(`WAVE ${this.wave} • ${activePirates} HOSTILE${activePirates === 1 ? '' : 'S'}`);
this.speedText.setText(`${Math.abs(state.speed || 0).toFixed(0)} ${state.speed < 0 ? 'REV' : 'FWD'}`);
this.headingText.setText(`${(state.heading || 0).toFixed(0).padStart(3, '0')}°`);
this.waveText.setText(`WAVE ${state.wave} • ${state.hostiles} HOSTILE${state.hostiles === 1 ? '' : 'S'}`);
this.combatText.setText(compact
? `K ${this.defeated}\n${rangeText}`
: `DEFEATED ${this.defeated}\nRANGE ${rangeText}`);
? `K ${state.kills}\n${rangeText}`
: `DEFEATED ${state.kills}\nRANGE ${rangeText}`);
this.playerHullLabel.setText(`SPARROW ${this.playerHull}/${PLAYER_MAX_HULL}`);
this.enemyHullLabel.setText(nearest
this.playerHullLabel.setText(`SPARROW ${playerHull}/${playerMaxHull}`);
this.enemyHullLabel.setText(target
? `${nearestClass} ${nearestHull}/${nearestMaxHull}`
: `WAVE ${this.wave} CLEAR`);
: `WAVE ${state.wave} CLEAR`);
const side = 14;
const gap = 14;
@ -866,9 +904,9 @@ class GameScene extends Phaser.Scene {
this.hudGraphics.fillRoundedRect(enemyX, barY, barWidth, barHeight, 4);
this.hudGraphics.fillStyle(0x61c98b, 1);
this.hudGraphics.fillRoundedRect(side, barY, barWidth * (this.playerHull / PLAYER_MAX_HULL), barHeight, 4);
this.hudGraphics.fillRoundedRect(side, barY, barWidth * (playerHull / playerMaxHull), barHeight, 4);
if (nearest) {
if (target) {
this.hudGraphics.fillStyle(0xd65757, 1);
this.hudGraphics.fillRoundedRect(enemyX, barY, barWidth * (nearestHull / nearestMaxHull), barHeight, 4);
}

View File

@ -6,7 +6,7 @@ export const PIRATE_CLASSES = {
speed: 148,
turnRate: 70,
reloadMs: 1280,
damage: 4,
projectileDamage: 4,
preferredRange: 300,
retreatRange: 175,
broadsideRange: 380,
@ -20,7 +20,7 @@ export const PIRATE_CLASSES = {
speed: 118,
turnRate: 52,
reloadMs: 1650,
damage: 6,
projectileDamage: 6,
preferredRange: 335,
retreatRange: 210,
broadsideRange: 420,
@ -34,7 +34,7 @@ export const PIRATE_CLASSES = {
speed: 102,
turnRate: 44,
reloadMs: 1450,
damage: 8,
projectileDamage: 8,
preferredRange: 365,
retreatRange: 225,
broadsideRange: 470,
@ -43,10 +43,93 @@ export const PIRATE_CLASSES = {
},
};
export const pirateClassForWave = (wave, index, count) => {
if (wave >= 4 && wave % 2 === 0 && index === count - 1) return PIRATE_CLASSES.captain;
if (wave >= 2 && (wave + index) % 3 === 0) return PIRATE_CLASSES.raider;
return PIRATE_CLASSES.frigate;
export const PIRATE_CLASS_COSTS = {
raider: 1,
frigate: 2,
captain: 3,
};
const normalizeDegrees = (deg) => ((deg % 360) + 360) % 360;
const signedAngleDelta = (target, current) => {
let delta = normalizeDegrees(target) - normalizeDegrees(current);
if (delta > 180) delta -= 360;
if (delta < -180) delta += 360;
return delta;
};
export const waveDifficultyBudget = (wave) => {
const safeWave = Math.max(1, Math.floor(Number(wave) || 1));
return safeWave + 1 + Math.floor((safeWave - 1) / 2);
};
export const pirateRosterForWave = (wave, maxPirates = 6) => {
const safeWave = Math.max(1, Math.floor(Number(wave) || 1));
const safeMaxPirates = Math.max(1, Math.floor(Number(maxPirates) || 1));
const count = Math.min(safeWave, safeMaxPirates);
let remainingBudget = Math.max(count, waveDifficultyBudget(safeWave));
let captainSlots = safeWave >= 4 ? Math.max(1, Math.floor(safeWave / 4)) : 0;
const roster = [];
for (let index = 0; index < count; index += 1) {
const remainingSlots = count - index - 1;
const minimumRemainingCost = remainingSlots * PIRATE_CLASS_COSTS.raider;
if (
captainSlots > 0
&& remainingBudget - PIRATE_CLASS_COSTS.captain >= minimumRemainingCost
) {
roster.push(PIRATE_CLASSES.captain);
remainingBudget -= PIRATE_CLASS_COSTS.captain;
captainSlots -= 1;
continue;
}
if (remainingBudget - PIRATE_CLASS_COSTS.frigate >= minimumRemainingCost) {
roster.push(PIRATE_CLASSES.frigate);
remainingBudget -= PIRATE_CLASS_COSTS.frigate;
continue;
}
roster.push(PIRATE_CLASSES.raider);
remainingBudget -= PIRATE_CLASS_COSTS.raider;
}
return roster;
};
export const pirateDesiredHeading = ({
bearing,
currentHeading,
distance,
preferredRange,
retreatRange,
}) => {
if (distance < retreatRange) {
return normalizeDegrees(bearing + 180);
}
if (distance < preferredRange + 40) {
const optionA = normalizeDegrees(bearing + 90);
const optionB = normalizeDegrees(bearing - 90);
return Math.abs(signedAngleDelta(optionA, currentHeading))
< Math.abs(signedAngleDelta(optionB, currentHeading))
? optionA
: optionB;
}
return normalizeDegrees(bearing);
};
export const pirateSpeedForRange = ({
distance,
preferredRange,
retreatRange,
baseSpeed,
}) => {
if (distance < retreatRange) return baseSpeed * 0.9;
if (distance > preferredRange + 100) return baseSpeed;
return baseSpeed * 0.82;
};
export const separationVector = (ship, ships, radius = 125) => {

View File

@ -3,104 +3,24 @@ import assert from 'node:assert/strict';
import {
DEFAULT_PIRATE_SHOT_DAMAGE,
PLAYER_HIT_COOLDOWN_MS,
PLAYER_MAX_HULL,
normalizePlayerDamage,
processQueuedPlayerHits,
reducePlayerHitQueue,
reducePlayerProjectileHits,
reducePlayerVolleyHits,
resolveTaggedProjectile,
} from '../src/game-logic.js';
test('first pirate hit removes exactly the default damage and does not end the game', () => {
const result = reducePlayerHitQueue({
test('first pirate projectile removes exactly the default damage', () => {
const result = reducePlayerProjectileHits({
hull: PLAYER_MAX_HULL,
lastHitAt: -Infinity,
now: 1000,
damages: [DEFAULT_PIRATE_SHOT_DAMAGE],
hits: [{ damage: DEFAULT_PIRATE_SHOT_DAMAGE }],
});
assert.equal(result.hull, 94);
assert.equal(result.appliedHits, 1);
assert.equal(result.ignoredHits, 0);
assert.equal(result.totalDamage, 6);
assert.equal(result.gameOver, false);
});
test('actual queued first player hit applies 6 damage without mutating projectile lifecycle', () => {
const shot = {
active: true,
destroyed: false,
destroy() {
this.destroyed = true;
this.active = false;
},
};
const firstFrame = processQueuedPlayerHits({
hull: PLAYER_MAX_HULL,
lastHitAt: -Infinity,
now: 1000,
hits: [{ shot, damage: DEFAULT_PIRATE_SHOT_DAMAGE }],
});
assert.equal(shot.destroyed, false);
assert.equal(shot.active, true);
assert.equal(firstFrame.hull, 94);
assert.equal(firstFrame.appliedHits, 1);
assert.equal(firstFrame.ignoredHits, 0);
assert.equal(firstFrame.gameOver, false);
const followingFrame = processQueuedPlayerHits({
hull: firstFrame.hull,
lastHitAt: firstFrame.lastHitAt,
now: 1016,
hits: [],
});
assert.equal(followingFrame.hull, 94);
assert.equal(followingFrame.gameOver, false);
assert.equal(followingFrame.appliedHits, 0);
});
test('three cannonballs arriving in one broadside only apply one hull hit', () => {
const result = reducePlayerHitQueue({
hull: 100,
lastHitAt: -Infinity,
now: 2000,
damages: [6, 6, 6],
});
assert.equal(result.hull, 94);
assert.equal(result.appliedHits, 1);
assert.equal(result.ignoredHits, 2);
});
test('hit inside cooldown is ignored and exact cooldown boundary is accepted', () => {
const blocked = reducePlayerHitQueue({
hull: 94,
lastHitAt: 1000,
now: 1000 + PLAYER_HIT_COOLDOWN_MS - 1,
damages: [6],
});
assert.equal(blocked.hull, 94);
assert.equal(blocked.appliedHits, 0);
assert.equal(blocked.ignoredHits, 1);
const accepted = reducePlayerHitQueue({
hull: 94,
lastHitAt: 1000,
now: 1000 + PLAYER_HIT_COOLDOWN_MS,
damages: [6],
});
assert.equal(accepted.hull, 88);
assert.equal(accepted.appliedHits, 1);
});
test('damage normalization handles invalid and extreme values safely', () => {
assert.equal(normalizePlayerDamage(undefined), 6);
assert.equal(normalizePlayerDamage('not-a-number'), 6);
@ -109,24 +29,20 @@ test('damage normalization handles invalid and extreme values safely', () => {
assert.equal(normalizePlayerDamage('8'), 8);
});
test('invalid hull state recovers from max hull before applying damage', () => {
const result = reducePlayerHitQueue({
test('invalid hull state recovers from max hull before applying projectile damage', () => {
const result = reducePlayerProjectileHits({
hull: Number.NaN,
lastHitAt: -Infinity,
now: 3000,
damages: [6],
hits: [{ damage: 6 }],
});
assert.equal(result.hull, 94);
assert.equal(result.gameOver, false);
});
test('lethal hit clamps hull at zero and reports game over', () => {
const result = reducePlayerHitQueue({
test('lethal projectile damage clamps hull at zero and reports game over', () => {
const result = reducePlayerProjectileHits({
hull: 5,
lastHitAt: -Infinity,
now: 4000,
damages: [6],
hits: [{ damage: 6 }],
});
assert.equal(result.hull, 0);
@ -134,97 +50,30 @@ test('lethal hit clamps hull at zero and reports game over', () => {
assert.equal(result.gameOver, true);
});
test('empty hit queue is a no-op', () => {
const result = reducePlayerHitQueue({
test('empty projectile queue is a no-op', () => {
const result = reducePlayerProjectileHits({
hull: 72,
lastHitAt: 1234,
now: 5000,
damages: [],
hits: [],
});
assert.equal(result.hull, 72);
assert.equal(result.lastHitAt, 1234);
assert.equal(result.totalDamage, 0);
assert.equal(result.appliedHits, 0);
assert.equal(result.ignoredHits, 0);
assert.equal(result.gameOver, false);
});
test('one pirate broadside damages once, and the next broadside damages again', () => {
const firstVolley = reducePlayerVolleyHits({
hull: 100,
hits: [
{ volleyId: 101, damage: 6 },
{ volleyId: 101, damage: 6 },
{ volleyId: 101, damage: 6 },
],
});
assert.equal(firstVolley.hull, 94);
assert.equal(firstVolley.totalDamage, 6);
assert.equal(firstVolley.acceptedHits.length, 1);
assert.equal(firstVolley.ignoredHits.length, 2);
const secondVolley = reducePlayerVolleyHits({
hull: firstVolley.hull,
seenVolleyIds: firstVolley.seenVolleyIds,
hits: [
{ volleyId: 102, damage: 6 },
{ volleyId: 102, damage: 6 },
],
});
assert.equal(secondVolley.hull, 88);
assert.equal(secondVolley.totalDamage, 6);
assert.equal(secondVolley.acceptedHits.length, 1);
assert.equal(secondVolley.ignoredHits.length, 1);
const repeatedSecondVolley = reducePlayerVolleyHits({
hull: secondVolley.hull,
seenVolleyIds: secondVolley.seenVolleyIds,
hits: [{ volleyId: 102, damage: 6 }],
});
assert.equal(repeatedSecondVolley.hull, 88);
assert.equal(repeatedSecondVolley.totalDamage, 0);
assert.equal(repeatedSecondVolley.acceptedHits.length, 0);
assert.equal(repeatedSecondVolley.ignoredHits.length, 1);
});
test('different pirate volleys can both damage even when processed together', () => {
const result = reducePlayerVolleyHits({
hull: 100,
hits: [
{ volleyId: 201, damage: 6 },
{ volleyId: 202, damage: 6 },
],
});
assert.equal(result.hull, 88);
assert.equal(result.totalDamage, 12);
assert.equal(result.acceptedHits.length, 2);
assert.equal(result.ignoredHits.length, 0);
});
test('every new pirate projectile can damage once with no cross-shot suppression', () => {
test('every distinct pirate projectile can damage once with no cross-shot suppression', () => {
const first = reducePlayerProjectileHits({
hull: 100,
hits: [{ damage: 6 }],
});
assert.equal(first.hull, 94);
assert.equal(first.totalDamage, 6);
assert.equal(first.appliedHits, 1);
assert.equal(first.gameOver, false);
const second = reducePlayerProjectileHits({
hull: first.hull,
hits: [{ damage: 6 }],
});
assert.equal(first.hull, 94);
assert.equal(second.hull, 88);
assert.equal(second.totalDamage, 6);
assert.equal(second.appliedHits, 1);
assert.equal(second.gameOver, false);
});
test('multiple distinct pirate projectiles in the same frame all apply damage', () => {
@ -242,7 +91,6 @@ test('multiple distinct pirate projectiles in the same frame all apply damage',
assert.equal(result.appliedHits, 3);
});
test('projectile resolver is independent of Phaser overlap argument order', () => {
const player = {
getData() { return undefined; },

View File

@ -1,19 +1,93 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { PIRATE_CLASSES, pirateClassForWave, separationVector } from '../src/pirate-ai.js';
import {
PIRATE_CLASSES,
PIRATE_CLASS_COSTS,
pirateDesiredHeading,
pirateRosterForWave,
pirateSpeedForRange,
separationVector,
waveDifficultyBudget,
} from '../src/pirate-ai.js';
test('pirate classes expose distinct combat roles', () => {
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.damage > PIRATE_CLASSES.frigate.damage);
assert.ok(PIRATE_CLASSES.captain.projectileDamage > PIRATE_CLASSES.frigate.projectileDamage);
});
test('waves introduce raiders and captains without exceeding the wave composition', () => {
assert.equal(pirateClassForWave(1, 0, 1).key, 'frigate');
assert.equal(pirateClassForWave(3, 0, 3).key, 'raider');
assert.equal(pirateClassForWave(4, 3, 4).key, 'captain');
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', () => {

View File

@ -105,15 +105,18 @@ test('player hit FX runs after queued hit processing, outside the overlap callba
});
test('player damage is per projectile with no global cooldown or volley suppression', async () => {
const source = await read('src/game-v4.js');
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(source, /PLAYER_HIT_COOLDOWN_MS/);
assert.doesNotMatch(source, /damagedPlayerVolleys/);
assert.doesNotMatch(source, /nextPirateVolleyId/);
assert.doesNotMatch(source, /volleyId/);
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 () => {
@ -171,15 +174,36 @@ test('game consumes analog helm vector and keeps broadside cooldowns independent
assert.match(source, /this\.lastPlayerShot\[broadside\]/);
});
test('pirate combat uses class stats and separation steering', async () => {
test('player firing and reload HUD share one reload duration source', async () => {
const source = await read('src/game-v4.js');
assert.match(source, /pirateClassForWave\(this\.wave, i, count\)/);
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('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'\)/);
assert.match(source, /nearestHull \/ nearestMaxHull/);
});
test('mobile cannon HUD shows independent reload progress', async () => {
@ -234,15 +258,20 @@ test('processed pirate shots are consumed and later projectiles start clean', as
});
test('non-lethal damage preserves the live player sprite and body', async () => {
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\.ensureLivePlayerState\(\)/);
assert.match(source, /if \(this\.gameState !== 'playing' \|\| this\.playerHull <= 0 \|\| !this\.player\) return/);
assert.match(source, /if \(!this\.player\.active\) this\.player\.setActive\(true\)/);
assert.match(source, /if \(!this\.player\.visible\) this\.player\.setVisible\(true\)/);
assert.match(source, /if \(this\.player\.alpha !== 1\) this\.player\.setAlpha\(1\)/);
assert.match(source, /if \(this\.player\.body && !this\.player\.body\.enable\)/);
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*=/);
});