diff --git a/src/game-logic.js b/src/game-logic.js index c5c7767..af1d58e 100644 --- a/src/game-logic.js +++ b/src/game-logic.js @@ -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; diff --git a/src/game-v4.js b/src/game-v4.js index ff0db2f..c48e356 100644 --- a/src/game-v4.js +++ b/src/game-v4.js @@ -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); } diff --git a/src/pirate-ai.js b/src/pirate-ai.js index 4878337..36a70ae 100644 --- a/src/pirate-ai.js +++ b/src/pirate-ai.js @@ -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) => { diff --git a/tests/game-logic.test.mjs b/tests/game-logic.test.mjs index 750ebc2..d0513ba 100644 --- a/tests/game-logic.test.mjs +++ b/tests/game-logic.test.mjs @@ -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; }, diff --git a/tests/pirate-ai.test.mjs b/tests/pirate-ai.test.mjs index b1390d8..c7c2ab8 100644 --- a/tests/pirate-ai.test.mjs +++ b/tests/pirate-ai.test.mjs @@ -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', () => { diff --git a/tests/wiring.test.mjs b/tests/wiring.test.mjs index 1668542..80ec413 100644 --- a/tests/wiring.test.mjs +++ b/tests/wiring.test.mjs @@ -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*=/); });