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('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 = 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,/); });