Sync dock hub missions and trade
This commit is contained in:
parent
c706cd028b
commit
4e662cd2f9
@ -132,7 +132,7 @@
|
||||
</div>
|
||||
<button class="mobile-control port" data-code="KeyQ" data-key="q" data-keycode="81" aria-label="Fire port broadside">PORT</button>
|
||||
<button class="mobile-control starboard" data-code="KeyE" data-key="e" data-keycode="69" aria-label="Fire starboard broadside">STARBOARD</button>
|
||||
<button class="mobile-control ship-action buy" data-action="open-shipyard" aria-label="Open shipyard">SHIPYARD</button>
|
||||
<button class="mobile-control ship-action buy" data-action="open-dock" aria-label="Dock at port">DOCK</button>
|
||||
<button class="mobile-control restart" data-code="KeyR" data-key="r" data-keycode="82" aria-label="Restart">↻</button>
|
||||
</div>
|
||||
|
||||
|
||||
372
src/dock-ui.js
Normal file
372
src/dock-ui.js
Normal file
@ -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 = `
|
||||
<div class="dock-backdrop"></div>
|
||||
<section class="dock-shell" role="dialog" aria-modal="true" aria-label="Dock">
|
||||
<header class="dock-header">
|
||||
<div>
|
||||
<span class="dock-kicker">SKYPORT DOCK</span>
|
||||
<h1 data-dock="port">PORT</h1>
|
||||
<p data-dock="port-meta">Trade • contracts • repairs</p>
|
||||
</div>
|
||||
<button class="dock-leave" type="button">SET SAIL</button>
|
||||
</header>
|
||||
|
||||
<div class="dock-resources">
|
||||
<span>COIN <strong data-dock="credits">0</strong></span>
|
||||
<span>CARGO <strong data-dock="cargo">0/0</strong></span>
|
||||
<span>SALVAGE <strong data-dock="salvage">0</strong></span>
|
||||
<span>MISSION <strong data-dock="mission">NONE</strong></span>
|
||||
</div>
|
||||
|
||||
<nav class="dock-nav" aria-label="Dock services">
|
||||
<button type="button" data-dock-tab="missions">MISSIONS</button>
|
||||
<button type="button" data-dock-tab="trade">TRADE</button>
|
||||
<button type="button" data-action="open-shipyard">SHIPYARD</button>
|
||||
</nav>
|
||||
|
||||
<main class="dock-content">
|
||||
<section class="dock-home" data-panel="home">
|
||||
<button class="dock-service" type="button" data-dock-tab="missions">
|
||||
<strong>MISSIONS</strong>
|
||||
<span>Take courier contracts and pirate bounties.</span>
|
||||
</button>
|
||||
<button class="dock-service" type="button" data-dock-tab="trade">
|
||||
<strong>TRADE</strong>
|
||||
<span>Buy low, carry cargo, sell at another skyport.</span>
|
||||
</button>
|
||||
<button class="dock-service" type="button" data-action="open-shipyard">
|
||||
<strong>SHIPYARD</strong>
|
||||
<span>Upgrade the Sparrow and manage recovered salvage.</span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="dock-panel" data-panel="missions" hidden>
|
||||
<div class="dock-section-head">
|
||||
<div><strong>CONTRACT BOARD</strong><span>One active mission at a time.</span></div>
|
||||
<button type="button" data-dock-tab="home">BACK</button>
|
||||
</div>
|
||||
<div class="dock-active-mission" data-dock="active-mission"></div>
|
||||
<div class="dock-list" data-dock="mission-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="dock-panel" data-panel="trade" hidden>
|
||||
<div class="dock-section-head">
|
||||
<div><strong>MARKET</strong><span>Prices vary by port exports and demand.</span></div>
|
||||
<button type="button" data-dock-tab="home">BACK</button>
|
||||
</div>
|
||||
<div class="dock-list" data-dock="market-list"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="dock-message" data-dock="message"></div>
|
||||
</section>
|
||||
`;
|
||||
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');
|
||||
});
|
||||
@ -1,3 +1,4 @@
|
||||
import './game-v4.js';
|
||||
import './mobile-hud.js';
|
||||
import './dock-ui.js';
|
||||
import './shipyard-ui.js';
|
||||
|
||||
178
src/game-v4.js
178
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 },
|
||||
|
||||
@ -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();
|
||||
|
||||
210
src/progression/dock-economy.js
Normal file
210
src/progression/dock-economy.js
Normal file
@ -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,
|
||||
};
|
||||
};
|
||||
@ -18,6 +18,8 @@ export const createStarterProgression = () => ({
|
||||
scrap: 0,
|
||||
loadout: { ...STARTER_LOADOUT },
|
||||
inventory: [],
|
||||
tradeCargo: {},
|
||||
activeMission: null,
|
||||
});
|
||||
|
||||
export const deriveShipStats = (loadout = STARTER_LOADOUT) => {
|
||||
|
||||
87
tests/dock-economy.test.mjs
Normal file
87
tests/dock-economy.test.mjs
Normal file
@ -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);
|
||||
});
|
||||
@ -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\)/);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user