airship-phaser/tests/wiring.test.mjs

480 lines
25 KiB
JavaScript

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\([\s\S]*?this\.player,[\s\S]*?this\.playerShots,[\s\S]*?'cannonball',[\s\S]*?this\.playerStats\?\.cannonDamage/);
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'\) \{[\s\S]*?this\.defeated \+= 1;[\s\S]*?this\.spawnSalvageWreck\(/);
});
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, /const effectiveForwardThrust = clamp\(this\.playerSpeed \/ stats\.maxSpeed, 0, 1\)/);
assert.match(source, /this\.player\.anims\.timeScale = 0\.65 \+ effectiveForwardThrust \* 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, /const effectiveForwardThrust = clamp\([\s\S]*?this\.playerStats\?\.maxSpeed \|\| PLAYER_MAX_SPEED[\s\S]*?\)/);
assert.match(source, /if \(this\.gameState !== 'playing' \|\| effectiveForwardThrust <= 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, effectiveForwardThrust\)/);
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, /const islands = this\.world\?\.islands \|\| ENVIRONMENT_ISLANDS/);
assert.match(source, /this\.physics\.add\.overlap\(this\.player, obstacle, \(\) => \{[\s\S]*?this\.playerSpeed \*= 0\.985;/);
assert.doesNotMatch(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('mobile controls expose native buy and salvage-fit actions', async () => {
const [game, hud, index] = await Promise.all([
read('src/game-v4.js'),
read('src/mobile-hud.js'),
read('index.html'),
]);
assert.match(index, /data-action="buy-upgrade"/);
assert.match(index, /data-action="fit-salvage"/);
assert.match(index, /CustomEvent\('airship:action'/);
assert.match(game, /addEventListener\('airship:action', this\.handleMobileAction\)/);
assert.match(game, /this\.pendingMobileActions\.delete\('buy-upgrade'\)/);
assert.match(game, /this\.pendingMobileActions\.delete\('fit-salvage'\)/);
assert.match(game, /marketUpgrade: marketUpgrade \? \{/);
assert.match(game, /salvageModule: salvageModule \? \{/);
assert.match(hud, /root\.querySelector\('\.mobile-control\.buy'\)/);
assert.match(hud, /root\.querySelector\('\.mobile-control\.fit'\)/);
assert.match(hud, /buyButton\.disabled = !progression\.docked \|\| !upgrade/);
assert.match(hud, /fitButton\.disabled = !module/);
});
test('mobile merchant traffic uses a reduced render budget', async () => {
const source = await read('src/game-v4.js');
assert.match(source, /const merchantLimit = desktopPointer \? MAX_MERCHANTS : Math\.min\(12, MAX_MERCHANTS\)/);
assert.match(source, /createMerchantTrafficPlans\(this\.world, \{ maxShips: merchantLimit \}\)/);
});
test('merchant traffic follows trade routes and pirates can raid it', async () => {
const source = await read('src/game-v4.js');
assert.match(source, /createMerchantTrafficPlans\(this\.world/);
assert.match(source, /this\.merchants = this\.physics\.add\.group\(\)/);
assert.match(source, /this\.createMerchantTraffic\(\)/);
assert.match(source, /merchantPointOnRoute\(/);
assert.match(source, /merchantRouteEndpoint\(/);
assert.match(source, /this\.updateMerchants\(dt\)/);
assert.match(source, /choosePirateTarget\(pirate\)/);
assert.match(source, /PIRATE_MERCHANT_DETECTION_RANGE/);
assert.match(source, /targetState\.kind === 'player'/);
assert.match(source, /this\.physics\.add\.overlap\(this\.pirateShots, this\.merchants, this\.hitMerchant/);
assert.match(source, /trade:merchant-destroyed/);
assert.match(source, /this\.spawnCargoWreck\(/);
});
test('procedural world and ship progression are wired into live gameplay', async () => {
const source = await read('src/game-v4.js');
assert.match(source, /generateWorld\(\{/);
assert.match(source, /this\.player\.setPosition\(this\.world\.spawn\.x, this\.world\.spawn\.y\)/);
assert.match(source, /pirateSpawnPointsForWave\(/);
assert.match(source, /for \(const route of this\.world\.tradeRoutes\)/);
assert.match(source, /for \(const territory of this\.world\.pirateTerritories\)/);
assert.match(source, /this\.salvageWrecks = this\.physics\.add\.group\(\)/);
assert.match(source, /collectSalvageWreck\(/);
assert.match(source, /bestAffordableMarketUpgrade\(/);
assert.match(source, /buyAndInstallModule\(/);
assert.match(source, /installModule\(this\.progression, moduleId\)/);
assert.match(source, /this\.playerBroadsideReloadMs = this\.playerStats\.reloadMs/);
assert.match(source, /stats\.windExposure/);
});
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 targetConcealment = targetState\.kind === 'player'/);
assert.match(source, /const targetObscured = targetConcealment >= 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,/);
});