From 4e662cd2f96f8bb2af8e633e989a0ffded8e6a36 Mon Sep 17 00:00:00 2001 From: Admin Date: Mon, 21 Sep 2026 00:03:35 +0000 Subject: [PATCH] Sync dock hub missions and trade --- index.html | 2 +- src/dock-ui.js | 372 ++++++++++++++++++++++++++++++++ src/game-entry.js | 1 + src/game-v4.js | 178 +++++++++++++-- src/mobile-hud.js | 11 +- src/progression/dock-economy.js | 210 ++++++++++++++++++ src/progression/ship-loadout.js | 2 + tests/dock-economy.test.mjs | 87 ++++++++ tests/wiring.test.mjs | 48 ++++- 9 files changed, 880 insertions(+), 31 deletions(-) create mode 100644 src/dock-ui.js create mode 100644 src/progression/dock-economy.js create mode 100644 tests/dock-economy.test.mjs diff --git a/index.html b/index.html index c6f66ac..a0fc39d 100644 --- a/index.html +++ b/index.html @@ -132,7 +132,7 @@ - + diff --git a/src/dock-ui.js b/src/dock-ui.js new file mode 100644 index 0000000..29675c6 --- /dev/null +++ b/src/dock-ui.js @@ -0,0 +1,372 @@ +const dispatchAction = (action, extra = {}) => { + window.dispatchEvent(new CustomEvent('airship:action', { + detail: { action, ...extra }, + })); +}; + +const overlay = document.createElement('div'); +overlay.id = 'dock-overlay'; +overlay.hidden = true; +overlay.innerHTML = ` +
+ +`; +document.body.appendChild(overlay); + +const style = document.createElement('style'); +style.textContent = ` + #dock-overlay { + position: fixed; + inset: 0; + z-index: 430; + color: #f0d9a0; + font-family: Georgia, 'Times New Roman', serif; + } + #dock-overlay[hidden] { display: none; } + .dock-backdrop { + position: absolute; + inset: 0; + background: rgba(2, 10, 15, .9); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + } + .dock-shell { + position: relative; + width: min(980px, calc(100vw - 18px)); + max-height: calc(100dvh - 18px); + min-height: min(720px, calc(100dvh - 18px)); + margin: max(9px, env(safe-area-inset-top)) auto; + padding: 14px; + overflow: auto; + border: 1px solid rgba(196, 140, 54, .64); + border-radius: 14px; + background: radial-gradient(circle at 50% 0%, rgba(20, 72, 84, .33), transparent 36%), #06161e; + box-shadow: 0 18px 60px rgba(0,0,0,.62); + overscroll-behavior: contain; + } + .dock-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 10px 13px; + border-bottom: 1px solid rgba(196, 140, 54, .34); + } + .dock-kicker { color: #adcfca; font: 700 10px/1 system-ui, sans-serif; letter-spacing: .18em; } + .dock-header h1 { margin: 3px 0 2px; color: #fff0bd; font-size: clamp(26px, 5vw, 46px); line-height: .95; } + .dock-header p { margin: 0; color: #9eb2b9; font: 600 11px/1.2 system-ui, sans-serif; } + .dock-leave, + .dock-nav button, + .dock-section-head button { + min-height: 44px; + border: 1px solid #9a6a2b; + border-radius: 9px; + background: linear-gradient(#174a50, #0a2930); + color: #ffe5a7; + font: 700 11px/1 Georgia, serif; + touch-action: manipulation; + } + .dock-leave { min-width: 112px; padding: 8px 14px; } + .dock-resources { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 7px; + padding: 10px 0; + } + .dock-resources span { + padding: 6px 11px; + border: 1px solid rgba(196, 140, 54, .38); + border-radius: 999px; + background: rgba(3, 20, 27, .82); + font-size: 11px; + } + .dock-resources strong { color: #fff0bd; } + .dock-nav { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; + margin: 2px 0 12px; + } + .dock-nav button { min-height: 52px; font-size: 13px; } + .dock-content { min-height: 420px; } + .dock-home { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; + } + .dock-service { + min-height: 250px; + padding: 18px; + border: 1px solid rgba(194, 142, 61, .56); + border-radius: 12px; + background: linear-gradient(180deg, rgba(13, 63, 72, .86), rgba(5, 25, 32, .96)); + color: #f0d9a0; + text-align: left; + touch-action: manipulation; + } + .dock-service strong { display:block; margin-bottom:8px; color:#fff1bc; font-size:22px; } + .dock-service span { color:#aac0c6; font:600 12px/1.45 system-ui,sans-serif; } + .dock-panel { display: grid; gap: 10px; } + .dock-section-head { display:flex; align-items:center; justify-content:space-between; gap:10px; } + .dock-section-head > div { display:grid; gap:2px; } + .dock-section-head strong { color:#fff0bd; font-size:18px; } + .dock-section-head span { color:#9fb3b9; font:600 10px/1.2 system-ui,sans-serif; } + .dock-section-head button { min-width:76px; padding:7px 10px; } + .dock-list { display:grid; gap:8px; } + .dock-card, + .dock-active-mission { + display:grid; + gap:5px; + padding:11px 12px; + border:1px solid rgba(190, 139, 59, .42); + border-radius:10px; + background:rgba(3, 21, 28, .88); + } + .dock-active-mission:empty { display:none; } + .dock-card-head { display:flex; align-items:flex-start; justify-content:space-between; gap:10px; } + .dock-card strong { color:#fff0bd; } + .dock-card small, + .dock-card p { margin:0; color:#9fb5bb; font:600 10px/1.35 system-ui,sans-serif; } + .dock-card-actions { display:flex; flex-wrap:wrap; gap:7px; margin-top:3px; } + .dock-card button, + .dock-active-mission button { + min-height:38px; + padding:6px 12px; + border:1px solid #9a6a2b; + border-radius:8px; + background:#103b3d; + color:#f5dda0; + font:700 10px/1 system-ui,sans-serif; + touch-action:manipulation; + } + .dock-card button:disabled, + .dock-active-mission button:disabled { opacity:.36; } + .dock-market-row { + display:grid; + grid-template-columns:minmax(120px,1.2fr) .8fr .8fr auto; + gap:8px; + align-items:center; + } + .dock-price { color:#fff0bd; font:700 11px/1 system-ui,sans-serif; } + .dock-tag { color:#98d3ab; font:700 9px/1 system-ui,sans-serif; } + .dock-message { min-height:1.3em; padding-top:9px; color:#fff0bd; text-align:center; font:700 11px/1.2 system-ui,sans-serif; } + body.dock-open #mobile-controls { visibility:hidden; } + + @media (max-width: 720px) { + .dock-shell { + width: calc(100vw - 8px); + max-height: calc(100dvh - 8px); + min-height: calc(100dvh - 8px); + margin: 4px auto; + padding: 8px; + border-radius: 9px; + } + .dock-header h1 { font-size: clamp(24px, 8vw, 34px); } + .dock-home { grid-template-columns:1fr; } + .dock-service { min-height:105px; padding:13px; } + .dock-service strong { font-size:18px; } + .dock-content { min-height:0; } + .dock-nav { position:sticky; top:-8px; z-index:2; padding:6px 0; background:#06161e; } + .dock-market-row { grid-template-columns:1fr 1fr; } + .dock-market-row .dock-card-actions { grid-column:1 / -1; } + } +`; +document.head.appendChild(style); + +const fields = Object.fromEntries( + [...overlay.querySelectorAll('[data-dock]')].map((el) => [el.dataset.dock, el]), +); +const panels = [...overlay.querySelectorAll('[data-panel]')]; +let latestState = null; + +const setPanel = (name = 'home') => { + panels.forEach((panel) => { panel.hidden = panel.dataset.panel !== name; }); +}; + +const actionButton = (label, action, extra = {}, disabled = false) => { + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = label; + button.disabled = disabled; + button.addEventListener('click', () => dispatchAction(action, extra)); + return button; +}; + +const renderMission = (state) => { + const dock = state?.dock || {}; + const active = dock.activeMission; + fields['active-mission'].replaceChildren(); + if (active) { + const title = document.createElement('strong'); + title.textContent = `ACTIVE: ${active.title}`; + const progress = document.createElement('small'); + progress.textContent = `${active.progressText} • Reward ¢${active.reward}`; + fields['active-mission'].append(title, progress); + if (active.ready) { + fields['active-mission'].append(actionButton('CLAIM REWARD', 'complete-mission')); + } + } + + fields['mission-list'].replaceChildren(); + for (const mission of dock.missionOffers || []) { + const card = document.createElement('article'); + card.className = 'dock-card'; + const head = document.createElement('div'); + head.className = 'dock-card-head'; + const title = document.createElement('strong'); + title.textContent = mission.title; + const reward = document.createElement('span'); + reward.className = 'dock-price'; + reward.textContent = `¢${mission.reward}`; + head.append(title, reward); + const desc = document.createElement('p'); + desc.textContent = mission.description; + const actions = document.createElement('div'); + actions.className = 'dock-card-actions'; + actions.append(actionButton( + active ? 'MISSION ACTIVE' : 'ACCEPT', + 'accept-mission', + { missionId: mission.id }, + Boolean(active), + )); + card.append(head, desc, actions); + fields['mission-list'].append(card); + } +}; + +const renderMarket = (state) => { + const dock = state?.dock || {}; + fields['market-list'].replaceChildren(); + for (const item of dock.market || []) { + const card = document.createElement('article'); + card.className = 'dock-card dock-market-row'; + const name = document.createElement('div'); + const strong = document.createElement('strong'); + strong.textContent = item.name; + const tag = document.createElement('small'); + tag.className = 'dock-tag'; + tag.textContent = item.exported ? 'LOCAL EXPORT' : item.imported ? 'HIGH DEMAND' : 'MARKET'; + name.append(strong, tag); + const buy = document.createElement('span'); + buy.className = 'dock-price'; + buy.textContent = `BUY ¢${item.buyPrice}`; + const sell = document.createElement('span'); + sell.className = 'dock-price'; + sell.textContent = `SELL ¢${item.sellPrice} • HOLD ${item.held || 0}`; + const actions = document.createElement('div'); + actions.className = 'dock-card-actions'; + actions.append( + actionButton('BUY 1', 'buy-trade-good', { goodId: item.goodId }, !item.canBuy), + actionButton('SELL 1', 'sell-trade-good', { goodId: item.goodId }, !item.canSell), + ); + card.append(name, buy, sell, actions); + fields['market-list'].append(card); + } +}; + +const render = (state) => { + if (!state?.dock) return; + latestState = state; + const dock = state.dock; + fields.port.textContent = dock.portName || 'DOCK'; + fields['port-meta'].textContent = `${String(dock.portType || 'trade').toUpperCase()} • ${dock.portExports?.join(', ') || 'mixed goods'}`; + fields.credits.textContent = state.progression?.credits ?? 0; + fields.cargo.textContent = `${dock.cargoUsed || 0}/${dock.cargoCapacity || 0}`; + fields.salvage.textContent = state.progression?.inventoryCount || 0; + fields.mission.textContent = dock.activeMission ? dock.activeMission.type.toUpperCase() : 'NONE'; + fields.message.textContent = String(state.status || '').replace(/\n/g, ' '); + renderMission(state); + renderMarket(state); +}; + +const show = (state) => { + render(state); + setPanel('home'); + overlay.hidden = false; + document.body.classList.add('dock-open'); +}; +const hide = () => { + overlay.hidden = true; + document.body.classList.remove('dock-open'); +}; + +for (const button of overlay.querySelectorAll('[data-dock-tab]')) { + button.addEventListener('click', () => setPanel(button.dataset.dockTab)); +} +for (const button of overlay.querySelectorAll('[data-action="open-shipyard"]')) { + button.addEventListener('click', () => dispatchAction('open-shipyard')); +} +overlay.querySelector('.dock-leave').addEventListener('click', () => dispatchAction('close-dock')); +overlay.querySelector('.dock-backdrop').addEventListener('click', () => dispatchAction('close-dock')); + +window.addEventListener('airship:dock-open', (event) => show(event.detail)); +window.addEventListener('airship:dock-state', (event) => render(event.detail)); +window.addEventListener('airship:dock-hide', () => hide()); +window.addEventListener('airship:dock-close', () => hide()); +window.addEventListener('airship:dock-return', (event) => show(event.detail || latestState)); +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-dock'); +}); diff --git a/src/game-entry.js b/src/game-entry.js index 029fec5..11c099e 100644 --- a/src/game-entry.js +++ b/src/game-entry.js @@ -1,3 +1,4 @@ import './game-v4.js'; import './mobile-hud.js'; +import './dock-ui.js'; import './shipyard-ui.js'; diff --git a/src/game-v4.js b/src/game-v4.js index eed66ee..0a556d7 100644 --- a/src/game-v4.js +++ b/src/game-v4.js @@ -43,6 +43,16 @@ import { upgradePathForSlot, } from './progression/ship-loadout.js'; import { MODULE_CATALOG } from './progression/modules.js'; +import { + acceptMission, + buyTradeGood, + completeMission, + marketAtPort, + missionOffersAtPort, + missionStatus, + sellTradeGood, + tradeCargoUnits, +} from './progression/dock-economy.js'; import { createMerchantTrafficPlans, merchantPointOnRoute, @@ -106,7 +116,9 @@ class GameScene extends Phaser.Scene { this.progression = createStarterProgression(); this.playerStats = deriveShipStats(this.progression.loadout); this.pendingMobileActions = new Set(); + this.pendingActionDetails = new Map(); this.pendingModulePurchaseId = null; + this.dockOpen = false; this.shipyardOpen = false; this.playerSpeed = 0; this.playerHull = this.playerStats.maxHull; @@ -230,6 +242,7 @@ class GameScene extends Phaser.Scene { this.handleMobileAction = (event) => { const action = String(event.detail?.action || ''); if (!action) return; + this.pendingActionDetails.set(action, { ...(event.detail || {}) }); if (action === 'purchase-module') { this.pendingModulePurchaseId = String(event.detail?.moduleId || ''); } @@ -550,39 +563,76 @@ class GameScene extends Phaser.Scene { const { port, range } = nearestPort(this.world, this.player.x, this.player.y); const docked = Boolean(port && range <= 230); - 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'); - const scrapSalvage = this.pendingMobileActions.delete('scrap-salvage'); + const takeAction = (action) => { + const requested = this.pendingMobileActions.delete(action); + const detail = this.pendingActionDetails.get(action) || null; + if (requested) this.pendingActionDetails.delete(action); + return requested ? (detail || {}) : null; + }; - if (closeRequested && this.shipyardOpen) { + const openDockAction = takeAction('open-dock'); + const openShipyardAction = takeAction('open-shipyard'); + const closeShipyardAction = takeAction('close-shipyard'); + const closeDockAction = takeAction('close-dock'); + const purchaseAction = takeAction('purchase-module'); + const fitAction = takeAction('fit-salvage'); + const scrapAction = takeAction('scrap-salvage'); + const buyTradeAction = takeAction('buy-trade-good'); + const sellTradeAction = takeAction('sell-trade-good'); + const acceptMissionAction = takeAction('accept-mission'); + const completeMissionAction = takeAction('complete-mission'); + + const keyboardDock = Phaser.Input.Keyboard.JustDown(this.keys.buyUpgrade); + const keyboardEscape = Phaser.Input.Keyboard.JustDown(this.keys.closeShipyard); + + if ((closeShipyardAction || (keyboardEscape && this.shipyardOpen)) && this.shipyardOpen) { this.shipyardOpen = false; this.pendingModulePurchaseId = null; window.dispatchEvent(new CustomEvent('airship:shipyard-close')); + window.dispatchEvent(new CustomEvent('airship:dock-return', { + detail: this.buildHudState(), + })); return; } - if (openRequested) { + if ((closeDockAction || (keyboardEscape && this.dockOpen && !this.shipyardOpen)) && this.dockOpen) { + this.shipyardOpen = false; + this.dockOpen = false; + this.pendingModulePurchaseId = null; + window.dispatchEvent(new CustomEvent('airship:shipyard-close')); + window.dispatchEvent(new CustomEvent('airship:dock-close')); + return; + } + + if (openDockAction || keyboardDock) { if (!docked) { - this.status.setText('GET WITHIN 230m OF A PORT TO ENTER SHIPYARD'); + this.status.setText('GET WITHIN 230m OF A PORT TO DOCK'); this.time.delayedCall(1400, () => { if (this.gameState === 'playing') this.status.setText(''); }); } else { - this.shipyardOpen = true; + this.dockOpen = true; + this.shipyardOpen = false; this.playerSpeed = 0; this.player.setVelocity(0, 0); - window.dispatchEvent(new CustomEvent('airship:shipyard-open', { + window.dispatchEvent(new CustomEvent('airship:dock-open', { detail: this.buildHudState(), })); } + return; } - if (purchaseRequested) { - const moduleId = this.pendingModulePurchaseId; + if (openShipyardAction && this.dockOpen && docked && !this.shipyardOpen) { + this.shipyardOpen = true; + window.dispatchEvent(new CustomEvent('airship:dock-hide')); + window.dispatchEvent(new CustomEvent('airship:shipyard-open', { + detail: this.buildHudState(), + })); + return; + } + + if (purchaseAction) { + const moduleId = String(purchaseAction.moduleId || this.pendingModulePurchaseId || ''); this.pendingModulePurchaseId = null; if (!this.shipyardOpen || !docked || !moduleId) return; @@ -607,7 +657,7 @@ class GameScene extends Phaser.Scene { })); } - if (mobileFit || Phaser.Input.Keyboard.JustDown(this.keys.fitSalvage)) { + if (fitAction || Phaser.Input.Keyboard.JustDown(this.keys.fitSalvage)) { if (!this.shipyardOpen || !docked) { this.status.setText('FIT RECOVERED MODULES AT THE SHIPYARD'); } else { @@ -630,7 +680,7 @@ class GameScene extends Phaser.Scene { } } - if (scrapSalvage) { + if (scrapAction) { if (!this.shipyardOpen || !docked) { this.status.setText('SCRAP RECOVERED MODULES AT THE SHIPYARD'); } else { @@ -648,6 +698,68 @@ class GameScene extends Phaser.Scene { } } + if (this.dockOpen && docked && !this.shipyardOpen) { + const market = marketAtPort(this.world, port); + + if (buyTradeAction) { + const item = market.find(({ goodId }) => goodId === buyTradeAction.goodId); + this.progression = buyTradeGood(this.progression, item, this.playerStats, 1); + this.status.setText( + this.progression.traded + ? `BOUGHT ${item?.name || 'CARGO'} // ¢${this.progression.credits}` + : this.progression.reason === 'cargo-full' + ? 'CARGO HOLD FULL' + : 'NOT ENOUGH COIN', + ); + } + + if (sellTradeAction) { + const item = market.find(({ goodId }) => goodId === sellTradeAction.goodId); + this.progression = sellTradeGood(this.progression, item, 1); + this.status.setText( + this.progression.traded + ? `SOLD ${item?.name || 'CARGO'} // ¢${this.progression.credits}` + : 'NONE OF THAT CARGO IN HOLD', + ); + } + + if (acceptMissionAction) { + const offers = missionOffersAtPort(this.world, port, this.defeated); + const mission = offers.find(({ id }) => id === acceptMissionAction.missionId); + this.progression = acceptMission( + this.progression, + mission, + this.playerStats, + this.defeated, + ); + this.status.setText( + this.progression.missionAccepted + ? `MISSION ACCEPTED\n${mission?.title || ''}` + : this.progression.reason === 'cargo-full' + ? 'NOT ENOUGH CARGO SPACE' + : 'FINISH CURRENT MISSION FIRST', + ); + } + + if (completeMissionAction) { + const activeBefore = this.progression.activeMission; + this.progression = completeMission(this.progression, { + portId: port.id, + defeated: this.defeated, + }); + this.status.setText( + this.progression.missionCompleted + ? `MISSION COMPLETE +¢${activeBefore?.reward || 0}` + : 'MISSION NOT COMPLETE YET', + ); + } + + if (buyTradeAction || sellTradeAction || acceptMissionAction || completeMissionAction) { + window.dispatchEvent(new CustomEvent('airship:dock-state', { + detail: this.buildHudState(), + })); + } + } } createEnvironmentPhysics() { @@ -1230,7 +1342,7 @@ class GameScene extends Phaser.Scene { } this.updateProgressionInteraction(); - if (this.shipyardOpen) { + if (this.dockOpen) { this.player.setVelocity(0, 0); const hudState = this.buildHudState(); if (!this.mobileHudActive) this.drawHud(hudState); @@ -1707,6 +1819,15 @@ class GameScene extends Phaser.Scene { upgradePathForSlot(this.progression.loadout, slot, marketStock), ]), ); + const dockMarket = docked ? marketAtPort(this.world, portState.port) : []; + const cargoUsed = tradeCargoUnits(this.progression); + const missionOffers = docked + ? missionOffersAtPort(this.world, portState.port, this.defeated) + : []; + const activeMission = missionStatus(this.progression, { + portId: docked ? portState.port?.id : null, + defeated: this.defeated, + }); return { gameState: this.gameState, @@ -1741,6 +1862,28 @@ class GameScene extends Phaser.Scene { losses: this.tradeLosses, playerLosses: this.playerTradeLosses, }, + dock: { + open: this.dockOpen, + shipyardOpen: this.shipyardOpen, + portId: docked ? portState.port?.id : null, + portName: docked ? portState.port?.name : null, + portType: docked ? portState.port?.type : null, + portExports: docked ? portState.port?.exports || [] : [], + cargoUsed, + cargoCapacity: this.playerStats?.cargoCapacity || 0, + market: dockMarket.map((item) => { + const held = this.progression.tradeCargo?.[item.goodId] || 0; + return { + ...item, + held, + canBuy: (this.progression.credits || 0) >= item.buyPrice + && cargoUsed < (this.playerStats?.cargoCapacity || 0), + canSell: held > 0, + }; + }), + missionOffers, + activeMission, + }, progression: { credits: this.progression?.credits || 0, scrap: this.progression?.scrap || 0, @@ -1748,6 +1891,7 @@ class GameScene extends Phaser.Scene { nearestPort: portState.port?.name || null, nearestPortRange: portState.range, docked, + dockOpen: this.dockOpen, shipyardOpen: this.shipyardOpen, shipStats: { ...this.playerStats }, loadout: { ...this.progression.loadout }, diff --git a/src/mobile-hud.js b/src/mobile-hud.js index f8167d2..1793f41 100644 --- a/src/mobile-hud.js +++ b/src/mobile-hud.js @@ -805,9 +805,14 @@ if (!isDesktopPointer.matches) { buyButton.hidden = !progression.docked; buyButton.disabled = !progression.docked; const salvageCount = progression.inventoryCount || 0; - buyButton.textContent = salvageCount > 0 - ? `SHIPYARD\nSALVAGE ${salvageCount}` - : 'SHIPYARD'; + const missionActive = Boolean(state.dock?.activeMission); + const badges = [ + missionActive ? 'MISSION' : '', + salvageCount > 0 ? `SALVAGE ${salvageCount}` : '', + ].filter(Boolean); + buyButton.textContent = badges.length + ? `DOCK\n${badges.join(' • ')}` + : 'DOCK'; } const statusText = String(state.status || '').trim(); diff --git a/src/progression/dock-economy.js b/src/progression/dock-economy.js new file mode 100644 index 0000000..4e22548 --- /dev/null +++ b/src/progression/dock-economy.js @@ -0,0 +1,210 @@ +import { createSeededRandom } from '../world/seeded-random.js'; + +const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); + +export const TRADE_GOODS = { + provisions: { id: 'provisions', name: 'Provisions', basePrice: 34 }, + ore: { id: 'ore', name: 'Ore', basePrice: 52 }, + fuel: { id: 'fuel', name: 'Aether Fuel', basePrice: 46 }, + machinery: { id: 'machinery', name: 'Machinery', basePrice: 82 }, +}; + +const TYPE_MODIFIERS = { + capital: { provisions: 0.96, ore: 1.02, fuel: 1.0, machinery: 0.94 }, + industrial: { provisions: 1.08, ore: 0.72, fuel: 0.96, machinery: 0.8 }, + fuel: { provisions: 1.08, ore: 1.04, fuel: 0.7, machinery: 1.06 }, + military: { provisions: 1.12, ore: 1.1, fuel: 1.04, machinery: 1.14 }, + trade: { provisions: 0.94, ore: 1.0, fuel: 1.0, machinery: 0.96 }, +}; + +const resourceToGood = (resource) => resource === 'food' + ? 'provisions' + : resource === 'ore' + ? 'ore' + : resource === 'fuel' + ? 'fuel' + : resource; + +export const tradeCargoUnits = (progression = {}) => { + const cargo = progression.tradeCargo || {}; + const goods = Object.values(cargo).reduce((sum, qty) => sum + Math.max(0, Number(qty) || 0), 0); + const missionUnits = progression.activeMission?.type === 'delivery' + ? Math.max(0, progression.activeMission.cargoUnits || 0) + : 0; + return goods + missionUnits; +}; + +export const marketAtPort = (world, port) => { + if (!port) return []; + const typeMods = TYPE_MODIFIERS[port.type] || TYPE_MODIFIERS.trade; + const exports = new Set((port.exports || []).map(resourceToGood)); + const imports = new Set((port.imports || []).map(resourceToGood)); + + return Object.values(TRADE_GOODS).map((good) => { + const random = createSeededRandom(`${world?.seed || 'world'}:${port.id}:${good.id}:market`); + const jitter = 0.93 + random() * 0.14; + let modifier = typeMods[good.id] || 1; + if (exports.has(good.id)) modifier *= 0.76; + if (imports.has(good.id)) modifier *= 1.2; + modifier *= 0.96 + (1 - (port.wealth || 0.5)) * 0.08; + + const localPrice = Math.max(8, Math.round(good.basePrice * modifier * jitter)); + return { + goodId: good.id, + name: good.name, + buyPrice: localPrice, + sellPrice: Math.max(5, Math.round(localPrice * 0.86)), + exported: exports.has(good.id), + imported: imports.has(good.id), + }; + }); +}; + +export const buyTradeGood = (progression, marketItem, stats, quantity = 1) => { + const qty = Math.max(1, Math.floor(quantity)); + const price = Math.max(0, marketItem?.buyPrice || 0) * qty; + if (!marketItem?.goodId || price <= 0) return { ...progression, traded: false, reason: 'invalid-good' }; + if ((progression.credits || 0) < price) return { ...progression, traded: false, reason: 'insufficient-credits' }; + if (tradeCargoUnits(progression) + qty > (stats?.cargoCapacity || 0)) { + return { ...progression, traded: false, reason: 'cargo-full' }; + } + + const tradeCargo = { ...(progression.tradeCargo || {}) }; + tradeCargo[marketItem.goodId] = (tradeCargo[marketItem.goodId] || 0) + qty; + return { + ...progression, + tradeCargo, + credits: progression.credits - price, + traded: true, + tradeAction: { type: 'buy', goodId: marketItem.goodId, quantity: qty, price }, + }; +}; + +export const sellTradeGood = (progression, marketItem, quantity = 1) => { + const qty = Math.max(1, Math.floor(quantity)); + const held = Math.max(0, progression.tradeCargo?.[marketItem?.goodId] || 0); + if (!marketItem?.goodId || held < qty) return { ...progression, traded: false, reason: 'not-held' }; + + const tradeCargo = { ...(progression.tradeCargo || {}) }; + tradeCargo[marketItem.goodId] = held - qty; + if (tradeCargo[marketItem.goodId] <= 0) delete tradeCargo[marketItem.goodId]; + const price = Math.max(0, marketItem.sellPrice || 0) * qty; + + return { + ...progression, + tradeCargo, + credits: (progression.credits || 0) + price, + traded: true, + tradeAction: { type: 'sell', goodId: marketItem.goodId, quantity: qty, price }, + }; +}; + +const routeDistance = (route) => Math.hypot( + (route?.to?.x || 0) - (route?.from?.x || 0), + (route?.to?.y || 0) - (route?.from?.y || 0), +); + +const connectedRoutes = (world, portId) => (world?.tradeRoutes || []) + .filter((route) => route.fromPortId === portId || route.toPortId === portId); + +const otherPortId = (route, portId) => route.fromPortId === portId + ? route.toPortId + : route.fromPortId; + +export const missionOffersAtPort = (world, port, defeated = 0) => { + if (!port) return []; + const routes = connectedRoutes(world, port.id) + .sort((a, b) => b.value - a.value || routeDistance(b) - routeDistance(a)); + const deliveryRoute = routes[0] || null; + const targetPort = deliveryRoute + ? world.ports.find(({ id }) => id === otherPortId(deliveryRoute, port.id)) + : null; + const random = createSeededRandom(`${world?.seed || 'world'}:${port.id}:missions`); + const offers = []; + + if (deliveryRoute && targetPort) { + const cargoUnits = 1 + Math.floor(random() * 2); + const distance = routeDistance(deliveryRoute); + const reward = Math.round(120 + distance * 0.11 + deliveryRoute.value * 160 + cargoUnits * 30); + offers.push({ + id: `delivery:${port.id}:${targetPort.id}`, + type: 'delivery', + title: `Courier Run → ${targetPort.name}`, + description: `Carry ${cargoUnits} sealed cargo unit${cargoUnits === 1 ? '' : 's'} to ${targetPort.name}.`, + originPortId: port.id, + originPortName: port.name, + targetPortId: targetPort.id, + targetPortName: targetPort.name, + cargoUnits, + reward, + }); + } + + const killTarget = 2 + Math.floor(random() * 3); + offers.push({ + id: `bounty:${port.id}:${killTarget}`, + type: 'bounty', + title: `Pirate Bounty ×${killTarget}`, + description: `Destroy ${killTarget} pirate airship${killTarget === 1 ? '' : 's'} and claim the bounty at any dock.`, + originPortId: port.id, + originPortName: port.name, + targetKills: killTarget, + reward: 150 + killTarget * 90, + baselineKills: defeated, + }); + + return offers; +}; + +export const acceptMission = (progression, mission, stats, defeated = 0) => { + if (!mission) return { ...progression, missionAccepted: false, reason: 'invalid-mission' }; + if (progression.activeMission) return { ...progression, missionAccepted: false, reason: 'mission-active' }; + if (mission.type === 'delivery' && tradeCargoUnits(progression) + (mission.cargoUnits || 0) > (stats?.cargoCapacity || 0)) { + return { ...progression, missionAccepted: false, reason: 'cargo-full' }; + } + + return { + ...progression, + activeMission: { + ...mission, + baselineKills: mission.type === 'bounty' ? defeated : mission.baselineKills, + }, + missionAccepted: true, + }; +}; + +export const missionStatus = (progression, { portId = null, defeated = 0 } = {}) => { + const mission = progression?.activeMission || null; + if (!mission) return null; + + if (mission.type === 'delivery') { + const ready = portId === mission.targetPortId; + return { + ...mission, + progressText: ready ? `Arrived at ${mission.targetPortName}` : `Deliver to ${mission.targetPortName}`, + ready, + }; + } + + const kills = Math.max(0, defeated - (mission.baselineKills || 0)); + return { + ...mission, + kills, + progressText: `${Math.min(kills, mission.targetKills)}/${mission.targetKills} pirates destroyed`, + ready: kills >= mission.targetKills, + }; +}; + +export const completeMission = (progression, context = {}) => { + const status = missionStatus(progression, context); + if (!status) return { ...progression, missionCompleted: false, reason: 'no-mission' }; + if (!status.ready) return { ...progression, missionCompleted: false, reason: 'not-complete' }; + + return { + ...progression, + credits: (progression.credits || 0) + Math.max(0, status.reward || 0), + activeMission: null, + missionCompleted: true, + lastCompletedMission: status, + }; +}; diff --git a/src/progression/ship-loadout.js b/src/progression/ship-loadout.js index c095416..756ae3a 100644 --- a/src/progression/ship-loadout.js +++ b/src/progression/ship-loadout.js @@ -18,6 +18,8 @@ export const createStarterProgression = () => ({ scrap: 0, loadout: { ...STARTER_LOADOUT }, inventory: [], + tradeCargo: {}, + activeMission: null, }); export const deriveShipStats = (loadout = STARTER_LOADOUT) => { diff --git a/tests/dock-economy.test.mjs b/tests/dock-economy.test.mjs new file mode 100644 index 0000000..993ca1f --- /dev/null +++ b/tests/dock-economy.test.mjs @@ -0,0 +1,87 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { generateWorld } from '../src/world/world-generator.js'; +import { createStarterProgression, deriveShipStats } from '../src/progression/ship-loadout.js'; +import { + acceptMission, + buyTradeGood, + completeMission, + marketAtPort, + missionOffersAtPort, + missionStatus, + sellTradeGood, + tradeCargoUnits, +} from '../src/progression/dock-economy.js'; + +test('port markets are deterministic and expose buy/sell prices', () => { + const world = generateWorld({ seed: 'DOCK-MARKET' }); + const port = world.ports[0]; + const a = marketAtPort(world, port); + const b = marketAtPort(world, port); + assert.deepEqual(a, b); + assert.ok(a.length >= 4); + assert.ok(a.every((item) => item.buyPrice > item.sellPrice)); +}); + +test('trade cargo respects capacity and selling returns coin', () => { + const world = generateWorld({ seed: 'DOCK-TRADE' }); + const market = marketAtPort(world, world.ports[0]); + const item = market[0]; + const starter = { ...createStarterProgression(), credits: 2000 }; + const stats = deriveShipStats(starter.loadout); + const bought = buyTradeGood(starter, item, stats); + assert.equal(bought.traded, true); + assert.equal(tradeCargoUnits(bought), 1); + assert.ok(bought.credits < starter.credits); + + const sold = sellTradeGood(bought, item); + assert.equal(sold.traded, true); + assert.equal(tradeCargoUnits(sold), 0); + assert.ok(sold.credits > bought.credits); +}); + +test('delivery missions follow generated trade routes and complete at destination', () => { + const world = generateWorld({ seed: 'DOCK-MISSIONS' }); + const origin = world.ports[0]; + const offers = missionOffersAtPort(world, origin, 0); + const delivery = offers.find(({ type }) => type === 'delivery'); + assert.ok(delivery); + assert.ok(world.tradeRoutes.some((route) => + (route.fromPortId === origin.id && route.toPortId === delivery.targetPortId) + || (route.toPortId === origin.id && route.fromPortId === delivery.targetPortId) + )); + + const starter = createStarterProgression(); + const stats = deriveShipStats(starter.loadout); + const accepted = acceptMission(starter, delivery, stats, 0); + assert.equal(accepted.missionAccepted, true); + assert.equal(missionStatus(accepted, { portId: origin.id, defeated: 0 }).ready, false); + + const completed = completeMission(accepted, { + portId: delivery.targetPortId, + defeated: 0, + }); + assert.equal(completed.missionCompleted, true); + assert.equal(completed.activeMission, null); + assert.equal(completed.credits, starter.credits + delivery.reward); +}); + +test('bounty missions advance from actual pirate kill count', () => { + const world = generateWorld({ seed: 'DOCK-BOUNTY' }); + const offers = missionOffersAtPort(world, world.ports[0], 5); + const bounty = offers.find(({ type }) => type === 'bounty'); + const starter = createStarterProgression(); + const stats = deriveShipStats(starter.loadout); + const accepted = acceptMission(starter, bounty, stats, 5); + const pending = missionStatus(accepted, { + portId: world.ports[0].id, + defeated: 5 + bounty.targetKills - 1, + }); + assert.equal(pending.ready, false); + const ready = missionStatus(accepted, { + portId: world.ports[0].id, + defeated: 5 + bounty.targetKills, + }); + assert.equal(ready.ready, true); +}); diff --git a/tests/wiring.test.mjs b/tests/wiring.test.mjs index 762b65c..a3b0471 100644 --- a/tests/wiring.test.mjs +++ b/tests/wiring.test.mjs @@ -263,21 +263,25 @@ test('environment physics adds wind, soft island collision, turbulence, and proj assert.match(source, /windSpeed: this\.environmentWind\?\.strength/); }); -test('mobile controls route salvage management through the docked shipyard', async () => { - const [game, hud, index] = await Promise.all([ +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-shipyard"/); - assert.doesNotMatch(index, /data-action="fit-salvage"/); - assert.match(index, /CustomEvent\('airship:action'/); - assert.match(game, /this\.pendingMobileActions\.delete\('open-shipyard'\)/); - assert.match(game, /this\.pendingMobileActions\.delete\('fit-salvage'\)/); - assert.match(game, /this\.pendingMobileActions\.delete\('scrap-salvage'\)/); - assert.match(hud, /buyButton\.textContent = salvageCount > 0/); - assert.doesNotMatch(hud, /const fitButton = root\.querySelector/); + 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, /dispatchAction\('open-shipyard'\)/); + assert.match(hud, /buyButton\.textContent = badges\.length/); }); test('shipyard overlay uses supplied component art and explicit module purchases', async () => { @@ -305,12 +309,36 @@ test('shipyard overlay uses supplied component art and explicit module purchases 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\)/); + assert.match(game, /missionOffersAtPort\(this\.world, port, this\.defeated\)/); + assert.match(game, /buyTradeGood\(/); + assert.match(game, /sellTradeGood\(/); + assert.match(game, /acceptMission\(/); + assert.match(game, /completeMission\(/); + 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/); +}); + 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\)/);