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.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, /reducePlayerVolleyHits\(\{/); 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, /\.steam-player-card \{[\s\S]*?width: min\(34vw, 360px\)/); assert.match(source, /\.steam-enemy-card \{[\s\S]*?width: min\(30vw, 330px\)/); }); 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 \(acceptedHits\.length > 0\) \{[\s\S]*?this\.spawnExplosion\(impact\.x, impact\.y, 0\.32\)/); assert.match(source, /this\.flashShip\(this\.player, 0xffcccc\)/); assert.match(source, /combat:player-hit-fx-error/); }); test('pirate broadsides assign a volley id and player damage deduplicates by volley', async () => { const source = await read('src/game-v4.js'); assert.match(source, /const volleyId = actor === 'pirate' \? this\.nextPirateVolleyId\+\+ : null/); assert.match(source, /shot\.setData\('volleyId', volleyId\)/); assert.match(source, /volleyId: shot\.getData\('volleyId'\)/); assert.match(source, /seenVolleyIds: this\.damagedPlayerVolleys/); assert.match(source, /this\.damagedPlayerVolleys = result\.seenVolleyIds/); });