Add dock hub with missions trade and shipyard

This commit is contained in:
Admin 2026-09-21 00:01:49 +00:00
parent 2488de8d96
commit 364a20e9b2
7 changed files with 298 additions and 31 deletions

View File

@ -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>

View File

@ -1,3 +1,4 @@
import './game-v4.js';
import './mobile-hud.js';
import './dock-ui.js';
import './shipyard-ui.js';

View File

@ -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 },

View File

@ -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();

View File

@ -18,6 +18,8 @@ export const createStarterProgression = () => ({
scrap: 0,
loadout: { ...STARTER_LOADOUT },
inventory: [],
tradeCargo: {},
activeMission: null,
});
export const deriveShipStats = (loadout = STARTER_LOADOUT) => {

View 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);
});

View File

@ -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, /this\.pendingMobileActions\.delete\('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\)/);