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/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/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..6e79683 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, /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\)/);