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('procedural world renders uploaded steampunk island variants while keeping soft terrain physics', async () => { const source = await read('src/game-v4.js'); assert.match(source, /small_island\.png\?url/); assert.match(source, /medium_island\.png\?url/); assert.match(source, /rocky_island\.png\?url/); assert.match(source, /pirate_island\.png\?url/); assert.match(source, /dock_island\.png\?url/); assert.match(source, /trading_outpost_island\.png\?url/); assert.match(source, /volcanic_island\.png\?url/); assert.match(source, /islandTextureFor\(island\)/); assert.match(source, /pirateHaven/); assert.match(source, /port\?\.type === 'capital'/); assert.match(source, /island\.resource === 'fuel'/); assert.match(source, /this\.add\.image\(island\.x, island\.y, texture\)/); assert.doesNotMatch(source, /islandGraphics\.fillEllipse/); assert.match(source, /this\.physics\.add\.overlap\(this\.player, obstacle/); assert.doesNotMatch(source, /this\.physics\.add\.collider\(this\.player, obstacle/); }); test('player settings owns account, multiplayer and local flight preferences', async () => { const [entry, settings, multiplayer, game] = await Promise.all([ read('src/game-entry.js'), read('src/player-settings.js'), read('src/multiplayer.js'), read('src/game-v4.js'), ]); assert.match(entry, /import '.\/player-settings\.js'/); assert.match(settings, /Player Settings/); assert.match(settings, /data-player-setting="multiplayerEnabled"/); assert.match(settings, /data-player-setting="showPlayerLabels"/); assert.match(settings, /data-player-setting="screenShake"/); assert.match(settings, /data-player-setting="reducedMotion"/); assert.match(settings, /window\.airshipMultiplayer\?\.setEnabled/); assert.match(settings, /window\.airshipMultiplayer\.logout\(\)/); assert.match(settings, /realms\/dev\/account/); assert.match(multiplayer, /airship:settings-open/); assert.match(multiplayer, /const setEnabled = \(enabled\) =>/); assert.match(multiplayer, /getState/); assert.match(multiplayer, /\/api\/me/); assert.match(multiplayer, /⚙ ONLINE/); assert.match(game, /window\.airshipSettings\?\.get\?\.\(\)\.screenShake/); assert.match(game, /showPlayerLabels/); }); test('startup shows a real progress loading screen until the Phaser scene is ready', async () => { const [index, game, multiplayer] = await Promise.all([ read('index.html'), read('src/game-v4.js'), read('src/multiplayer.js'), ]); assert.match(index, /id="airship-loading-screen"/); assert.match(index, /Preparing the Sparrow/); assert.match(index, /body class="airship-loading"/); assert.match(index, /window\.airshipLoading = \{ setProgress, setNetwork, finish \}/); assert.match(index, /airship:game-ready/); assert.match(game, /this\.load\.on\('progress'/); assert.match(game, /airship:loading-progress/); assert.match(game, /airship:game-ready/); assert.match(multiplayer, /window\.airshipLoading\?\.setNetwork/); assert.match(index, /body\.airship-loading #mobile-controls/); }); 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, /const reloadMs = multiplayerOnline \? PLAYER_BROADSIDE_RELOAD_MS : this\.playerBroadsideReloadMs/); assert.match(source, /this\.time\.now - this\.lastPlayerShot\[broadside\] < reloadMs/); 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\(52, 100\)/); assert.match(source, /this\.player\.body\.setCircle\(34, 30, 94\)/); 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 = this\.weatherWindAt\(this\.player\.x, this\.player\.y\)/); assert.match(source, /const pirateWind = this\.weatherWindAt\(pirate\.x, pirate\.y\)/); assert.match(source, /const shotWind = this\.weatherWindAt\(x, y\)/); assert.match(source, /const localWind = this\.weatherWindAt\(sternX, sternY\)/); assert.match(source, /windSpeed: this\.environmentWind\?\.strength/); }); test('larger shared world uses smaller player ships and wider fake-captain spacing', async () => { const [game, world, server] = await Promise.all([ read('src/game-v4.js'), read('src/world/world-generator.js'), read('server/main.go'), ]); assert.match(game, /const WORLD_SIZE = 5400/); assert.match(game, /islandCount: 24/); assert.match(game, /portCount: 9/); assert.match(game, /setDisplaySize\(52, 100\)/); assert.match(game, /setCircle\(34, 30, 94\)/); assert.match(game, /setDisplaySize\(48, 92\)/); assert.match(game, /this\.cameras\.main\.setZoom\(1\.0\)/); assert.match(world, /width = 5400/); assert.match(world, /islandCount = 24/); assert.match(server, /worldSize\s+= 5400\.0/); assert.match(server, /shipHitRadius\s+= 34\.0/); assert.match(server, /math\.Cos\(phase\)\*650/); assert.match(server, /distance <= 1100/); }); test('shipping-route weather is shared by client visuals and authoritative multiplayer movement', async () => { const [game, environment, multiplayer, server] = await Promise.all([ read('src/game-v4.js'), read('src/environment-physics.js'), read('src/multiplayer.js'), read('server/main.go'), ]); assert.match(environment, /routeWeatherAt/); assert.match(environment, /stormFrontAt/); assert.match(environment, /\? 'shipping-lane'/); assert.match(environment, /\? 'route-edge'/); assert.match(game, /routes: this\.world\?\.tradeRoutes/); assert.match(game, /routeCloudiness/); assert.match(game, /multiplayerServerClockOffsetMs/); assert.match(game, /setWeatherRoutes\?\.\(this\.world\.tradeRoutes\)/); assert.match(game, /weatherZone: this\.environmentWind\?\.zone/); assert.match(multiplayer, /type: 'weather-layout'/); assert.match(multiplayer, /setWeatherRoutes/); assert.match(server, /type weatherRoute struct/); assert.match(server, /func \(h \*hub\) setWeatherRoutes/); assert.match(server, /func \(h \*hub\) weatherWindAtLocked/); assert.match(server, /stepPlayerWithWind/); assert.match(server, /case "weather-layout":/); }); test('authoritative multiplayer server seeds fake captains that move and fight like players', async () => { const server = await read('server/main.go'); assert.match(server, /FakePlayers:\s+envInt\("FAKE_PLAYERS", 3\)/); assert.match(server, /fakeCaptainNames/); assert.match(server, /"Mara Voss"/); assert.match(server, /"Brass Jack"/); assert.match(server, /"Finn Gale"/); assert.match(server, /Bot\s+bool\s+\`json:"bot,omitempty"\`/); assert.match(server, /fakeCaptainDecisionLocked/); assert.match(server, /h\.fireLocked\(c, fireSide, now\)/); assert.match(server, /if c\.conn != nil/); assert.match(server, /hub: newHub\(cfg\.WorldSeed, cfg\.FakePlayers\)/); }); test('multiplayer transport uses Keycloak tickets with server-authoritative movement and PvP', async () => { const [game, entry, multiplayer, pkg, server] = await Promise.all([ read('src/game-v4.js'), read('src/game-entry.js'), read('src/multiplayer.js'), read('package.json'), read('server/main.go'), ]); assert.match(entry, /import '.\/multiplayer\.js'/); assert.match(pkg, /"keycloak-js"/); assert.match(multiplayer, /clientId: 'airship-web'/); assert.match(multiplayer, /\/api\/ws-ticket/); assert.match(multiplayer, /type: 'input'/); assert.match(multiplayer, /type: 'fire'/); assert.doesNotMatch(multiplayer, /type: 'state'/); assert.match(game, /airship:multiplayer-snapshot/); assert.match(game, /syncAuthoritativeSelf/); assert.match(game, /syncMultiplayerProjectiles/); assert.match(game, /updateMultiplayerPredictedPlayer/); assert.match(game, /window\.airshipMultiplayer\?\.sendInput/); assert.match(game, /window\.airshipMultiplayer\?\.fire/); assert.match(game, /this\.multiplayerDead/); assert.match(server, /type inputState struct/); assert.match(server, /func \(h \*hub\) updateInput/); assert.match(server, /func \(h \*hub\) fire/); assert.match(server, /func \(h \*hub\) step/); assert.match(server, /stepPlayerWithWind\(&c\.state, c\.input/); assert.match(server, /target\.state\.Hull = clampInt/); assert.match(server, /target\.state\.RespawnAt/); assert.match(server, /time\.NewTicker\(simTick\)/); assert.match(server, /snapshotTick/); assert.doesNotMatch(server, /msg\.X/); assert.doesNotMatch(server, /msg\.Hull/); }); test('mobile controls enter a dock hub before shipyard, trade, or missions', async () => { const [game, hud, index, entry, dockUi] = await Promise.all([ read('src/game-v4.js'), read('src/mobile-hud.js'), read('index.html'), read('src/game-entry.js'), read('src/dock-ui.js'), ]); assert.match(index, /data-action="open-dock"/); assert.match(entry, /import '.\/dock-ui\.js'/); assert.match(game, /const openDockAction = takeAction\('open-dock'\)/); assert.match(game, /this\.dockOpen = true/); assert.match(game, /if \(this\.dockOpen\)/); assert.match(dockUi, /MISSIONS/); assert.match(dockUi, /TRADE/); assert.match(dockUi, /SHIPYARD/); assert.match(dockUi, /#dock-overlay \[hidden\] \{ display: none !important; \}/); assert.match(dockUi, /panel\.hidden = !active/); assert.match(dockUi, /panel\.setAttribute\('aria-hidden'/); assert.match(dockUi, /dispatchAction\('open-shipyard'\)/); assert.match(hud, /buyButton\.textContent = badges\.length/); assert.match(hud, /steam-objective/); assert.match(hud, /objective-arrow/); assert.match(hud, /mission\.relativeBearing/); }); test('shipyard overlay uses supplied component art and explicit module purchases', async () => { const [game, entry, shipyard, modules] = await Promise.all([ read('src/game-v4.js'), read('src/game-entry.js'), read('src/shipyard-ui.js'), read('src/progression/modules.js'), ]); assert.match(entry, /import '.\/shipyard-ui\.js'/); assert.match(shipyard, /shipyard-ui\/title_banner\.png\?url/); assert.match(shipyard, /shipyard-ui\/ship_selector\.png\?url/); assert.match(shipyard, /shipyard-ui\/ship_preview\.png\?url/); assert.match(shipyard, /shipyard-ui\/upgrades\.png\?url/); assert.match(shipyard, /airship:shipyard-open/); assert.match(shipyard, /dispatchAction\('purchase-module', \{ moduleId \}\)/); assert.match(shipyard, /data-upgrade-slot/); assert.match(shipyard, /CURRENT:/); assert.match(shipyard, /NEXT:/); assert.match(shipyard, /dispatchAction\('scrap-salvage', \{ inventoryIndex: selectedSalvageIndex \}\)/); assert.match(shipyard, /selectedSalvageIndex/); assert.match(shipyard, /inventoryIndex: selectedSalvageIndex/); assert.match(shipyard, /salvageItems/); assert.doesNotMatch( shipyard, /airship:hud-state'[\s\S]{0,180}if \(!overlay\.hidden\) render\(event\.detail\)/, ); assert.match(shipyard, /Pirate wrecks repair hull immediately on pickup/); assert.doesNotMatch(shipyard, /data-shipyard="active"/); assert.match(shipyard, /min-height: calc\(100dvh - 8px\)/); assert.match(game, /marketForPort\(port\)/); assert.match(game, /airship:shipyard-state/); assert.match(game, /this\.shipyardOpen = true/); assert.match(game, /airship:dock-hide/); assert.match(game, /airship:dock-return/); assert.match(modules, /'reinforced-rudder'/); assert.match(modules, /price: 800/); assert.match(modules, /price: 600/); assert.match(modules, /price: 400/); }); test('dock trade and missions are driven by generated port economy state', async () => { const [game, economy, dockUi] = await Promise.all([ read('src/game-v4.js'), read('src/progression/dock-economy.js'), read('src/dock-ui.js'), ]); assert.match(game, /marketAtPort\(this\.world, port, this\.progression\.economy\)/); assert.match(game, /missionOffersAtPort\([\s\S]*?this\.world,[\s\S]*?port,[\s\S]*?this\.defeated,[\s\S]*?this\.progression\.completedMissions/); assert.match(game, /buyTradeGood\(/); assert.match(game, /sellTradeGood\(/); assert.match(game, /acceptMission\(/); assert.match(game, /completeMission\(/); assert.match(game, /recordRouteTradeEvent\(/); assert.match(game, /bestTradeDestination\(/); assert.match(game, /payForHullRepair\(/); assert.match(game, /objective = \{/); assert.match(game, /dock: \{/); assert.match(economy, /connectedRoutes\(world, port\.id\)/); assert.match(economy, /port\.exports/); assert.match(dockUi, /buy-trade-good/); assert.match(dockUi, /sell-trade-good/); assert.match(dockUi, /accept-mission/); assert.match(dockUi, /complete-mission/); assert.match(dockUi, /repair-hull/); assert.match(dockUi, /BEST KNOWN:/); assert.match(dockUi, /data-dock="wanted"/); assert.match(dockUi, /airship:dock-state/); assert.match(dockUi, /latestState = event\.detail/); assert.doesNotMatch( dockUi, /airship:hud-state'[\s\S]{0,180}if \(!overlay\.hidden\) render\(event\.detail\)/, ); }); 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, /this\.playerHull = Math\.min\([\s\S]*?this\.playerHull \+ repairAvailable/); assert.match(source, /MODULE STORED:[\s\S]*?FIT OR SCRAP AT SHIPYARD/); assert.match(source, /upgradePathForSlot\(/); assert.match(source, /describeInventoryEntry\(/); assert.match(source, /installInventoryModule\(/); assert.match(source, /salvageItems/); assert.match(source, /installedConditions/); assert.match(source, /bestAffordableMarketUpgrade\(/); assert.match(source, /buyAndInstallModule\(/); assert.match(source, /installInventoryModule\(this\.progression, inventoryIndex\)/); 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\) \/ 24, 0, 1\)/); assert.match(source, /const routeCloudiness = 0\.32/); assert.match(source, /\(weatherWave \* 0\.62 \+ windPresence \* 0\.32 - 0\.1\) \* routeCloudiness/); 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('HUD skips hidden Phaser rendering while DOM state remains available to overlays', 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 \(!state\) return/); assert.match(source, /if \(this\.mobileHudActive && !force && now - this\.lastHudPublishAt < HUD_PUBLISH_INTERVAL_MS\) 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,/); });