diff --git a/index.html b/index.html index 2850b8a..a1f7473 100644 --- a/index.html +++ b/index.html @@ -132,7 +132,7 @@ - + diff --git a/src/game-entry.js b/src/game-entry.js index 35dbbc1..029fec5 100644 --- a/src/game-entry.js +++ b/src/game-entry.js @@ -1,2 +1,3 @@ import './game-v4.js'; import './mobile-hud.js'; +import './shipyard-ui.js'; diff --git a/src/game-v4.js b/src/game-v4.js index bcce4f9..d6fc565 100644 --- a/src/game-v4.js +++ b/src/game-v4.js @@ -36,6 +36,7 @@ import { createStarterProgression, deriveShipStats, installModule, + marketForPort, salvageForPirate, } from './progression/ship-loadout.js'; import { MODULE_CATALOG } from './progression/modules.js'; @@ -102,6 +103,8 @@ class GameScene extends Phaser.Scene { this.progression = createStarterProgression(); this.playerStats = deriveShipStats(this.progression.loadout); this.pendingMobileActions = new Set(); + this.pendingModulePurchaseId = null; + this.shipyardOpen = false; this.playerSpeed = 0; this.playerHull = this.playerStats.maxHull; this.gameState = 'playing'; @@ -223,7 +226,11 @@ class GameScene extends Phaser.Scene { window.addEventListener('airship:helm-vector', this.handleHelmVector); this.handleMobileAction = (event) => { const action = String(event.detail?.action || ''); - if (action) this.pendingMobileActions.add(action); + if (!action) return; + if (action === 'purchase-module') { + this.pendingModulePurchaseId = String(event.detail?.moduleId || ''); + } + this.pendingMobileActions.add(action); }; window.addEventListener('airship:action', this.handleMobileAction); this.keys = this.input.keyboard.addKeys({ @@ -233,6 +240,7 @@ class GameScene extends Phaser.Scene { restart: Phaser.Input.Keyboard.KeyCodes.R, buyUpgrade: Phaser.Input.Keyboard.KeyCodes.B, fitSalvage: Phaser.Input.Keyboard.KeyCodes.U, + closeShipyard: Phaser.Input.Keyboard.KeyCodes.ESC, }); this.cameras.main.setBounds(0, 0, this.world.width, this.world.height); @@ -527,29 +535,60 @@ class GameScene extends Phaser.Scene { const { port, range } = nearestPort(this.world, this.player.x, this.player.y); const docked = Boolean(port && range <= 230); - - const mobileBuy = this.pendingMobileActions.delete('buy-upgrade'); + const openRequested = this.pendingMobileActions.delete('open-shipyard') + || Phaser.Input.Keyboard.JustDown(this.keys.buyUpgrade); + const closeRequested = this.pendingMobileActions.delete('close-shipyard') + || Phaser.Input.Keyboard.JustDown(this.keys.closeShipyard); + const purchaseRequested = this.pendingMobileActions.delete('purchase-module'); const mobileFit = this.pendingMobileActions.delete('fit-salvage'); - if (mobileBuy || Phaser.Input.Keyboard.JustDown(this.keys.buyUpgrade)) { + if (closeRequested && this.shipyardOpen) { + this.shipyardOpen = false; + this.pendingModulePurchaseId = null; + window.dispatchEvent(new CustomEvent('airship:shipyard-close')); + return; + } + + if (openRequested) { if (!docked) { - this.status.setText('GET WITHIN 230m OF A PORT TO BUY UPGRADES'); + this.status.setText('GET WITHIN 230m OF A PORT TO ENTER SHIPYARD'); + this.time.delayedCall(1400, () => { + if (this.gameState === 'playing') this.status.setText(''); + }); } else { - const module = bestAffordableMarketUpgrade(this.progression, port); - if (!module) { - this.status.setText(`${port.name}\nNO AFFORDABLE UPGRADES`); - } else { - const previousMaxHull = this.playerStats.maxHull; - this.progression = buyAndInstallModule(this.progression, module.id); - this.applyProgressionStats(previousMaxHull); - this.status.setText( - `${port.name}\nINSTALLED ${module.name} // ¢${this.progression.credits}`, - ); - } + this.shipyardOpen = true; + this.playerSpeed = 0; + this.player.setVelocity(0, 0); + window.dispatchEvent(new CustomEvent('airship:shipyard-open', { + detail: this.buildHudState(), + })); } - this.time.delayedCall(1500, () => { - if (this.gameState === 'playing') this.status.setText(''); - }); + } + + if (purchaseRequested) { + const moduleId = this.pendingModulePurchaseId; + this.pendingModulePurchaseId = null; + if (!this.shipyardOpen || !docked || !moduleId) return; + + const stock = marketForPort(port); + const module = stock.find(({ id }) => id === moduleId); + if (!module) { + this.status.setText('MODULE NOT STOCKED AT THIS PORT'); + } else if (this.progression.loadout?.[module.slot] === module.id) { + this.status.setText(`${module.name}\nALREADY INSTALLED`); + } else if (this.progression.credits < module.price) { + this.status.setText(`${module.name}\nNEED ¢${module.price}`); + } else { + const previousMaxHull = this.playerStats.maxHull; + this.progression = buyAndInstallModule(this.progression, module.id); + this.applyProgressionStats(previousMaxHull); + this.status.setText( + `INSTALLED ${module.name}\n¢${this.progression.credits} REMAINING`, + ); + } + window.dispatchEvent(new CustomEvent('airship:shipyard-state', { + detail: this.buildHudState(), + })); } if (mobileFit || Phaser.Input.Keyboard.JustDown(this.keys.fitSalvage)) { @@ -563,6 +602,11 @@ class GameScene extends Phaser.Scene { const module = MODULE_CATALOG[moduleId]; this.status.setText(`FITTED SALVAGE\n${module?.name || moduleId}`); } + if (this.shipyardOpen) { + window.dispatchEvent(new CustomEvent('airship:shipyard-state', { + detail: this.buildHudState(), + })); + } this.time.delayedCall(1400, () => { if (this.gameState === 'playing') this.status.setText(''); }); @@ -1147,8 +1191,16 @@ class GameScene extends Phaser.Scene { }, 'debug'); } - this.updatePlayer(dt, deltaMs); this.updateProgressionInteraction(); + if (this.shipyardOpen) { + this.player.setVelocity(0, 0); + const hudState = this.buildHudState(); + if (!this.mobileHudActive) this.drawHud(hudState); + this.publishHudState(hudState, true); + return; + } + + this.updatePlayer(dt, deltaMs); this.updatePlayerSmoke(deltaMs); this.updateRollingClouds(deltaMs); this.updateCloudConcealment(); @@ -1605,6 +1657,7 @@ class GameScene extends Phaser.Scene { const portState = nearestPort(this.world, player.x, player.y); const influence = pirateInfluenceAt(this.world, player.x, player.y); const docked = Boolean(portState.port && portState.range <= 230); + const marketStock = docked ? marketForPort(portState.port) : []; const marketUpgrade = docked ? bestAffordableMarketUpgrade(this.progression, portState.port) : null; @@ -1651,6 +1704,15 @@ class GameScene extends Phaser.Scene { nearestPort: portState.port?.name || null, nearestPortRange: portState.range, docked, + shipyardOpen: this.shipyardOpen, + shipStats: { ...this.playerStats }, + loadout: { ...this.progression.loadout }, + marketStock: marketStock.map((module) => ({ + id: module.id, + name: module.name, + slot: module.slot, + price: module.price, + })), marketUpgrade: marketUpgrade ? { id: marketUpgrade.id, name: marketUpgrade.name, @@ -1674,10 +1736,10 @@ class GameScene extends Phaser.Scene { } publishHudState(state = this.buildHudState(), force = false) { - if (!this.mobileHudActive || !state) return; + if (!state) return; const now = this.time?.now ?? 0; - if (!force && now - this.lastHudPublishAt < HUD_PUBLISH_INTERVAL_MS) return; + if (this.mobileHudActive && !force && now - this.lastHudPublishAt < HUD_PUBLISH_INTERVAL_MS) return; this.lastHudPublishAt = now; window.dispatchEvent(new CustomEvent('airship:hud-state', { detail: state })); diff --git a/src/mobile-hud.js b/src/mobile-hud.js index 0f8abca..f642fc2 100644 --- a/src/mobile-hud.js +++ b/src/mobile-hud.js @@ -799,13 +799,10 @@ if (!isDesktopPointer.matches) { const progression = state.progression || {}; if (buyButton) { - const upgrade = progression.marketUpgrade; - buyButton.disabled = !progression.docked || !upgrade; - buyButton.textContent = !progression.docked - ? 'BUY\nPORT' - : upgrade - ? `BUY\n${upgrade.name} ¢${upgrade.price}` - : 'BUY\nNO UPGRADE'; + buyButton.disabled = !progression.docked; + buyButton.textContent = progression.docked + ? 'SHIPYARD' + : 'SHIPYARD\nPORT'; } if (fitButton) { const module = progression.salvageModule; diff --git a/src/progression/modules.js b/src/progression/modules.js index 6cc6780..d4366c5 100644 --- a/src/progression/modules.js +++ b/src/progression/modules.js @@ -24,10 +24,10 @@ export const MODULE_CATALOG = { }, 'tuned-propellers': { id: 'tuned-propellers', - name: 'Tuned Propellers', + name: 'Engine Lv 1', slot: 'engine', rarity: 'improved', - price: 280, + price: 800, modifiers: { maxSpeed: 20, acceleration: 10 }, }, 'corsair-overdrive': { @@ -72,10 +72,10 @@ export const MODULE_CATALOG = { }, 'reinforced-keel': { id: 'reinforced-keel', - name: 'Reinforced Keel', + name: 'Hull Plating Lv 1', slot: 'hull', rarity: 'improved', - price: 420, + price: 600, modifiers: { maxHull: 28, maxSpeed: -7 }, }, 'stormglass-nav': { @@ -104,12 +104,20 @@ export const MODULE_CATALOG = { }, 'expanded-hold': { id: 'expanded-hold', - name: 'Expanded Cargo Hold', + name: 'Cargo Hold Lv 1', slot: 'cargo', rarity: 'improved', - price: 350, + price: 600, modifiers: { cargoCapacity: 4, maxSpeed: -4 }, }, + 'reinforced-rudder': { + id: 'reinforced-rudder', + name: 'Rudder Lv 1', + slot: 'rudder', + rarity: 'improved', + price: 400, + modifiers: { turnRate: 12 }, + }, }; export const STARTER_LOADOUT = { @@ -119,6 +127,7 @@ export const STARTER_LOADOUT = { navigation: 'brass-compass', utility: null, cargo: null, + rudder: null, }; export const purchasableModules = () => Object.values(MODULE_CATALOG) diff --git a/src/shipyard-ui.js b/src/shipyard-ui.js new file mode 100644 index 0000000..0505a53 --- /dev/null +++ b/src/shipyard-ui.js @@ -0,0 +1,465 @@ +import titleBannerUrl from './assets/shipyard-ui/title_banner.png?url'; +import shipSelectorUrl from './assets/shipyard-ui/ship_selector.png?url'; +import shipPreviewUrl from './assets/shipyard-ui/ship_preview.png?url'; +import upgradesUrl from './assets/shipyard-ui/upgrades.png?url'; +import activeUrl from './assets/shipyard-ui/active.png?url'; +import closeButtonUrl from './assets/shipyard-ui/close_button.png?url'; + +const UPGRADE_SLOTS = [ + { id: 'tuned-propellers', label: 'Engine Lv 1' }, + { id: 'reinforced-keel', label: 'Hull Plating Lv 1' }, + { id: 'expanded-hold', label: 'Cargo Hold Lv 1' }, + { id: 'reinforced-rudder', label: 'Rudder Lv 1' }, +]; + +const dispatchAction = (action, extra = {}) => { + window.dispatchEvent(new CustomEvent('airship:action', { + detail: { action, ...extra }, + })); +}; + +const overlay = document.createElement('div'); +overlay.id = 'shipyard-overlay'; +overlay.hidden = true; +overlay.innerHTML = ` +
+ +`; +document.body.appendChild(overlay); + +const style = document.createElement('style'); +style.textContent = ` + #shipyard-overlay { + position: fixed; + inset: 0; + z-index: 450; + color: #f4d99a; + font-family: Georgia, 'Times New Roman', serif; + touch-action: pan-y; + } + + #shipyard-overlay[hidden] { display: none; } + + .shipyard-backdrop { + position: absolute; + inset: 0; + background: rgba(3, 11, 16, .9); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + } + + .shipyard-shell { + position: relative; + width: min(1180px, calc(100vw - 24px)); + max-height: calc(100dvh - 20px); + margin: max(10px, env(safe-area-inset-top)) auto; + padding: 10px; + overflow: auto; + border: 1px solid rgba(211, 157, 65, .6); + border-radius: 14px; + background: + radial-gradient(circle at 50% 5%, rgba(20, 70, 83, .34), transparent 38%), + linear-gradient(180deg, #061b24 0%, #041218 100%); + box-shadow: 0 18px 60px rgba(0, 0, 0, .65); + overscroll-behavior: contain; + } + + .shipyard-header { + position: relative; + display: flex; + justify-content: center; + min-height: 70px; + } + + .shipyard-title { + width: min(595px, 76vw); + height: auto; + object-fit: contain; + } + + .shipyard-close { + position: absolute; + top: 0; + right: max(0px, env(safe-area-inset-right)); + width: clamp(48px, 7vw, 72px); + aspect-ratio: 1; + padding: 0; + border: 0; + background: transparent; + cursor: pointer; + touch-action: manipulation; + } + + .shipyard-close img { width: 100%; height: 100%; object-fit: contain; } + + .shipyard-resources { + display: flex; + gap: 8px; + justify-content: center; + flex-wrap: wrap; + margin: 4px auto 10px; + font-size: clamp(11px, 1.6vw, 15px); + } + + .shipyard-resources span { + min-width: 120px; + padding: 7px 12px; + border: 1px solid rgba(198, 145, 59, .42); + border-radius: 999px; + background: rgba(3, 20, 27, .88); + text-align: center; + } + + .shipyard-resources strong { color: #fff0bd; } + + .shipyard-grid { + display: grid; + grid-template-columns: minmax(170px, .72fr) minmax(260px, 1fr) minmax(360px, 1.2fr); + gap: 10px; + align-items: start; + } + + .shipyard-selector img, + .shipyard-preview, + .shipyard-upgrades { + display: block; + width: 100%; + height: auto; + user-select: none; + -webkit-user-select: none; + } + + .shipyard-preview-wrap, + .shipyard-upgrade-wrap { position: relative; } + + .shipyard-live-stats { + position: absolute; + left: 5%; + right: 5%; + bottom: 3%; + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 4px 10px; + padding: 8px 10px; + border: 1px solid rgba(213, 162, 76, .48); + border-radius: 8px; + background: rgba(3, 18, 24, .86); + font-size: clamp(10px, 1.25vw, 13px); + text-shadow: 0 1px 2px #000; + } + + .shipyard-live-stats strong { + grid-column: 1 / -1; + color: #fff0bd; + letter-spacing: .12em; + } + + .shipyard-upgrade-hotspots { + position: absolute; + left: 3.5%; + right: 3.5%; + top: 18%; + bottom: 10%; + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 2.5%; + } + + .shipyard-upgrade-button { + position: relative; + border: 0; + border-radius: 8px; + background: transparent; + cursor: pointer; + touch-action: manipulation; + } + + .shipyard-upgrade-button:not(:disabled):active { background: rgba(255, 214, 120, .12); } + .shipyard-upgrade-button:disabled { cursor: default; } + + .shipyard-upgrade-state { + position: absolute; + left: 50%; + bottom: 3px; + transform: translateX(-50%); + padding: 2px 5px; + border-radius: 999px; + background: rgba(4, 16, 21, .88); + color: #ffe4a1; + font: 700 clamp(8px, 1vw, 11px)/1 system-ui, sans-serif; + white-space: nowrap; + } + + .shipyard-upgrade-button.installed .shipyard-upgrade-state { color: #aef0b8; } + .shipyard-upgrade-button.locked .shipyard-upgrade-state { color: #d8a8a0; } + + .shipyard-active-plate { + position: absolute; + left: 50%; + bottom: -58px; + width: min(255px, 55%); + transform: translateX(-50%); + } + + .shipyard-active-plate img { display: block; width: 100%; } + .shipyard-active-plate span { + position: absolute; + inset: 0; + display: grid; + place-items: center; + color: #b9efc0; + font-size: clamp(9px, 1.15vw, 13px); + letter-spacing: .08em; + } + + .shipyard-footer { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + min-height: 72px; + margin-top: 56px; + } + + .shipyard-salvage { + min-width: 150px; + min-height: 44px; + padding: 7px 14px; + border: 1px solid #b88131; + border-radius: 9px; + background: linear-gradient(#174e43, #0d302a); + color: #e6f5d0; + font: 700 12px/1.15 Georgia, serif; + touch-action: manipulation; + } + + .shipyard-salvage:disabled { opacity: .35; } + + .shipyard-message { + min-height: 1.2em; + max-width: 50%; + color: #fff0bd; + font-size: 12px; + } + + body.shipyard-open #mobile-controls { visibility: hidden; } + + @media (max-width: 760px), (max-height: 520px) { + .shipyard-shell { + width: calc(100vw - 8px); + max-height: calc(100dvh - 8px); + margin: 4px auto; + padding: 6px; + border-radius: 8px; + } + + .shipyard-header { min-height: 46px; } + .shipyard-title { width: min(460px, 68vw); } + .shipyard-close { width: 46px; } + + .shipyard-resources { gap: 4px; margin: 2px 0 6px; } + .shipyard-resources span { min-width: 0; padding: 4px 7px; font-size: 10px; } + + .shipyard-grid { + grid-template-columns: minmax(150px, .8fr) minmax(330px, 1.55fr); + gap: 6px; + } + + .shipyard-selector { display: none; } + + .shipyard-preview-wrap { max-height: 64dvh; overflow: hidden; } + .shipyard-preview { + height: min(64dvh, 430px); + width: 100%; + object-fit: cover; + object-position: center; + } + + .shipyard-live-stats { + left: 3%; + right: 3%; + bottom: 2%; + grid-template-columns: 1fr; + gap: 2px; + padding: 5px 7px; + font-size: 9px; + } + + .shipyard-upgrade-wrap { align-self: center; } + .shipyard-active-plate { bottom: -46px; width: 45%; } + + .shipyard-footer { + min-height: 54px; + margin-top: 46px; + gap: 7px; + } + + .shipyard-salvage { min-width: 120px; min-height: 40px; font-size: 10px; } + .shipyard-message { max-width: 56%; font-size: 10px; } + } + + @media (orientation: portrait) and (max-width: 760px) { + .shipyard-title { width: min(500px, 80vw); } + .shipyard-grid { grid-template-columns: 1fr; } + .shipyard-preview-wrap { display: none; } + .shipyard-upgrade-wrap { width: min(96vw, 520px); margin: 0 auto; } + .shipyard-footer { margin-top: 48px; } + } +`; +document.head.appendChild(style); + +const fields = Object.fromEntries( + [...overlay.querySelectorAll('[data-shipyard]')].map((el) => [el.dataset.shipyard, el]), +); +const closeButton = overlay.querySelector('.shipyard-close'); +const fitButton = overlay.querySelector('.shipyard-salvage'); +const upgradeButtons = [...overlay.querySelectorAll('.shipyard-upgrade-button')]; + +let latestState = null; + +const findStock = (id) => + latestState?.progression?.marketStock?.find?.((module) => module.id === id) || null; + +const render = (state) => { + if (!state?.progression) return; + latestState = state; + + const progression = state.progression; + const stats = progression.shipStats || {}; + const loadout = progression.loadout || {}; + + fields.port.textContent = progression.nearestPort || '--'; + fields.credits.textContent = progression.credits ?? 0; + fields.scrap.textContent = progression.scrap ?? 0; + fields.inventory.textContent = progression.inventoryCount ?? 0; + fields.hull.textContent = `Hull ${stats.maxHull ?? 100}`; + fields.speed.textContent = `Speed ${stats.maxSpeed ?? 180}`; + fields.firepower.textContent = `Firepower ${stats.cannonDamage ?? 16}`; + fields.handling.textContent = `Handling ${stats.turnRate ?? 76}`; + fields.cargo.textContent = `Cargo ${stats.cargoCapacity ?? 4}`; + + for (const button of upgradeButtons) { + const id = button.dataset.moduleId; + const stock = findStock(id); + const installed = Object.values(loadout).includes(id); + const stateLabel = button.querySelector('.shipyard-upgrade-state'); + + button.classList.toggle('installed', installed); + button.classList.toggle('locked', !stock && !installed); + button.disabled = installed || !stock || progression.credits < (stock?.price || Infinity); + + stateLabel.textContent = installed + ? 'INSTALLED' + : !stock + ? 'NOT STOCKED' + : progression.credits < stock.price + ? `NEED ¢${stock.price}` + : 'TAP TO BUY'; + } + + const salvage = progression.salvageModule; + fitButton.disabled = !salvage; + fitButton.textContent = salvage ? `FIT ${salvage.name}` : 'FIT SALVAGE'; + fields.message.textContent = String(state.status || '').replace(/\n/g, ' '); +}; + +const show = (state) => { + render(state); + overlay.hidden = false; + document.body.classList.add('shipyard-open'); + closeButton.focus({ preventScroll: true }); +}; + +const hide = () => { + overlay.hidden = true; + document.body.classList.remove('shipyard-open'); +}; + +closeButton.addEventListener('click', () => { + dispatchAction('close-shipyard'); + hide(); +}); + +overlay.querySelector('.shipyard-backdrop').addEventListener('click', () => { + dispatchAction('close-shipyard'); + hide(); +}); + +for (const button of upgradeButtons) { + button.addEventListener('click', () => { + const moduleId = button.dataset.moduleId; + if (!moduleId || button.disabled) return; + dispatchAction('purchase-module', { moduleId }); + }); +} + +fitButton.addEventListener('click', () => { + if (fitButton.disabled) return; + dispatchAction('fit-salvage'); +}); + +window.addEventListener('airship:shipyard-open', (event) => show(event.detail)); +window.addEventListener('airship:shipyard-state', (event) => render(event.detail)); +window.addEventListener('airship:shipyard-close', hide); +window.addEventListener('airship:hud-state', (event) => { + latestState = event.detail; + if (!overlay.hidden) render(event.detail); +}); +window.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && !overlay.hidden) { + dispatchAction('close-shipyard'); + hide(); + } +}); diff --git a/tests/progression.test.mjs b/tests/progression.test.mjs index d2fdb49..d754558 100644 --- a/tests/progression.test.mjs +++ b/tests/progression.test.mjs @@ -31,9 +31,12 @@ test('installed modules derive meaningful ship stat changes', () => { test('port purchase deducts credits and installs the module', () => { const starter = createStarterProgression(); const result = buyAndInstallModule(starter, 'tuned-propellers'); - assert.equal(result.purchased, true); - assert.equal(result.credits, starter.credits - 280); - assert.equal(result.loadout.engine, 'tuned-propellers'); + assert.equal(result.purchased, false); + const funded = { ...starter, credits: 1000 }; + const fundedResult = buyAndInstallModule(funded, 'tuned-propellers'); + assert.equal(fundedResult.purchased, true); + assert.equal(fundedResult.credits, funded.credits - 800); + assert.equal(fundedResult.loadout.engine, 'tuned-propellers'); }); test('pirate salvage is deterministic and collectible within salvage capacity', () => { @@ -53,3 +56,13 @@ test('market can select an affordable upgrade for a port', () => { assert.ok(upgrade); assert.ok(upgrade.price <= starter.credits); }); + + +test('rudder tier matches shipyard art and improves handling', () => { + const starter = { ...createStarterProgression(), credits: 650 }; + const result = buyAndInstallModule(starter, 'reinforced-rudder'); + const stats = deriveShipStats(result.loadout); + assert.equal(result.purchased, true); + assert.equal(result.credits, 250); + assert.equal(stats.turnRate, 88); +}); diff --git a/tests/wiring.test.mjs b/tests/wiring.test.mjs index cba4590..67d572a 100644 --- a/tests/wiring.test.mjs +++ b/tests/wiring.test.mjs @@ -270,20 +270,44 @@ test('mobile controls expose native buy and salvage-fit actions', async () => { read('index.html'), ]); - assert.match(index, /data-action="buy-upgrade"/); + assert.match(index, /data-action="open-shipyard"/); 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\('open-shipyard'\)/); 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, /buyButton\.disabled = !progression\.docked/); assert.match(hud, /fitButton\.disabled = !module/); }); +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(game, /marketForPort\(port\)/); + assert.match(game, /airship:shipyard-state/); + assert.match(game, /this\.shipyardOpen = true/); + assert.match(modules, /'reinforced-rudder'/); + assert.match(modules, /price: 800/); + assert.match(modules, /price: 600/); + assert.match(modules, /price: 400/); +}); + 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\)/); @@ -363,13 +387,14 @@ test('dense clouds conceal ships and suppress long-range pirate fire', async () assert.match(source, /concealment: this\.player\?\.getData\?\.\('cloudConcealment'\) \|\| 0/); }); -test('mobile HUD skips hidden Phaser rendering and throttles DOM state publishing', async () => { +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 \(!this\.mobileHudActive \|\| !state\) return/); + 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\(\)\)/); });