Sync connected progression economy and mission UX

This commit is contained in:
Admin 2026-09-21 01:13:49 +00:00
parent 2f28570334
commit b19a3c01f8
9 changed files with 633 additions and 49 deletions

View File

@ -24,6 +24,7 @@ overlay.innerHTML = `
<span>CARGO <strong data-dock="cargo">0/0</strong></span> <span>CARGO <strong data-dock="cargo">0/0</strong></span>
<span>SALVAGE <strong data-dock="salvage">0</strong></span> <span>SALVAGE <strong data-dock="salvage">0</strong></span>
<span>MISSION <strong data-dock="mission">NONE</strong></span> <span>MISSION <strong data-dock="mission">NONE</strong></span>
<span>WANTED <strong data-dock="wanted">0</strong></span>
</div> </div>
<nav class="dock-nav" aria-label="Dock services"> <nav class="dock-nav" aria-label="Dock services">
@ -46,6 +47,13 @@ overlay.innerHTML = `
<strong>SHIPYARD</strong> <strong>SHIPYARD</strong>
<span>Upgrade the Sparrow and manage recovered salvage.</span> <span>Upgrade the Sparrow and manage recovered salvage.</span>
</button> </button>
<div class="dock-repair-strip">
<div>
<strong>HULL SERVICE</strong>
<span data-dock="repair-status">Hull already at full strength.</span>
</div>
<button type="button" data-action="repair-hull">REPAIR</button>
</div>
</section> </section>
<section class="dock-panel" data-panel="missions" hidden> <section class="dock-panel" data-panel="missions" hidden>
@ -166,6 +174,32 @@ style.textContent = `
} }
.dock-service strong { display:block; margin-bottom:8px; color:#fff1bc; font-size:22px; } .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-service span { color:#aac0c6; font:600 12px/1.45 system-ui,sans-serif; }
.dock-repair-strip {
grid-column: 1 / -1;
display:flex;
align-items:center;
justify-content:space-between;
gap:12px;
padding:12px 14px;
border:1px solid rgba(194, 142, 61, .46);
border-radius:10px;
background:rgba(4, 28, 35, .9);
}
.dock-repair-strip > div { display:grid; gap:3px; }
.dock-repair-strip strong { color:#fff0bd; font-size:14px; }
.dock-repair-strip span { color:#9fb5bb; font:600 10px/1.3 system-ui,sans-serif; }
.dock-repair-strip button {
min-width:110px;
min-height:42px;
padding:7px 12px;
border:1px solid #9a6a2b;
border-radius:8px;
background:#103b3d;
color:#f5dda0;
font:700 10px/1 system-ui,sans-serif;
touch-action:manipulation;
}
.dock-repair-strip button:disabled { opacity:.36; }
.dock-panel { display: grid; gap: 10px; } .dock-panel { display: grid; gap: 10px; }
.dock-section-head { display:flex; align-items:center; justify-content:space-between; 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 > div { display:grid; gap:2px; }
@ -309,7 +343,13 @@ const renderMarket = (state) => {
strong.textContent = item.name; strong.textContent = item.name;
const tag = document.createElement('small'); const tag = document.createElement('small');
tag.className = 'dock-tag'; tag.className = 'dock-tag';
tag.textContent = item.exported ? 'LOCAL EXPORT' : item.imported ? 'HIGH DEMAND' : 'MARKET'; tag.textContent = item.shortage > 0.5
? `SHORTAGE +${Math.round(item.shortage * 8)}%`
: item.exported
? 'LOCAL EXPORT'
: item.imported
? 'HIGH DEMAND'
: 'MARKET';
name.append(strong, tag); name.append(strong, tag);
const buy = document.createElement('span'); const buy = document.createElement('span');
buy.className = 'dock-price'; buy.className = 'dock-price';
@ -317,13 +357,20 @@ const renderMarket = (state) => {
const sell = document.createElement('span'); const sell = document.createElement('span');
sell.className = 'dock-price'; sell.className = 'dock-price';
sell.textContent = `SELL ¢${item.sellPrice} • HOLD ${item.held || 0}`; sell.textContent = `SELL ¢${item.sellPrice} • HOLD ${item.held || 0}`;
const hint = document.createElement('p');
const opportunity = item.opportunity;
hint.textContent = opportunity
? opportunity.margin > 0
? `BEST KNOWN: ${opportunity.portName} • SELL ¢${opportunity.sellPrice} • +¢${opportunity.margin}/UNIT`
: `BEST KNOWN: ${opportunity.portName} • no positive margin`
: 'No known destination price.';
const actions = document.createElement('div'); const actions = document.createElement('div');
actions.className = 'dock-card-actions'; actions.className = 'dock-card-actions';
actions.append( actions.append(
actionButton('BUY 1', 'buy-trade-good', { goodId: item.goodId }, !item.canBuy), actionButton('BUY 1', 'buy-trade-good', { goodId: item.goodId }, !item.canBuy),
actionButton('SELL 1', 'sell-trade-good', { goodId: item.goodId }, !item.canSell), actionButton('SELL 1', 'sell-trade-good', { goodId: item.goodId }, !item.canSell),
); );
card.append(name, buy, sell, actions); card.append(name, buy, sell, hint, actions);
fields['market-list'].append(card); fields['market-list'].append(card);
} }
}; };
@ -338,6 +385,19 @@ const render = (state) => {
fields.cargo.textContent = `${dock.cargoUsed || 0}/${dock.cargoCapacity || 0}`; fields.cargo.textContent = `${dock.cargoUsed || 0}/${dock.cargoCapacity || 0}`;
fields.salvage.textContent = state.progression?.inventoryCount || 0; fields.salvage.textContent = state.progression?.inventoryCount || 0;
fields.mission.textContent = dock.activeMission ? dock.activeMission.type.toUpperCase() : 'NONE'; fields.mission.textContent = dock.activeMission ? dock.activeMission.type.toUpperCase() : 'NONE';
fields.wanted.textContent = dock.wanted || 0;
const repairButton = overlay.querySelector('[data-action="repair-hull"]');
const repair = dock.repair;
if (repair) {
fields['repair-status'].textContent = repair.missing > 0
? `Repair ${repair.missing} hull for ¢${repair.cost}.`
: 'Hull already at full strength.';
repairButton.disabled = repair.missing <= 0
|| (state.progression?.credits || 0) < repair.cost;
repairButton.textContent = repair.missing > 0 ? `REPAIR ¢${repair.cost}` : 'FULL HULL';
}
fields.message.textContent = String(state.status || '').replace(/\n/g, ' '); fields.message.textContent = String(state.status || '').replace(/\n/g, ' ');
renderMission(state); renderMission(state);
renderMarket(state); renderMarket(state);
@ -360,6 +420,8 @@ for (const button of overlay.querySelectorAll('[data-dock-tab]')) {
for (const button of overlay.querySelectorAll('[data-action="open-shipyard"]')) { for (const button of overlay.querySelectorAll('[data-action="open-shipyard"]')) {
button.addEventListener('click', () => dispatchAction('open-shipyard')); button.addEventListener('click', () => dispatchAction('open-shipyard'));
} }
overlay.querySelector('[data-action="repair-hull"]')
.addEventListener('click', () => dispatchAction('repair-hull'));
overlay.querySelector('.dock-leave').addEventListener('click', () => dispatchAction('close-dock')); overlay.querySelector('.dock-leave').addEventListener('click', () => dispatchAction('close-dock'));
overlay.querySelector('.dock-backdrop').addEventListener('click', () => dispatchAction('close-dock')); overlay.querySelector('.dock-backdrop').addEventListener('click', () => dispatchAction('close-dock'));

View File

@ -36,6 +36,7 @@ import {
createStarterProgression, createStarterProgression,
deriveShipStats, deriveShipStats,
describeInventoryEntry, describeInventoryEntry,
installInventoryModule,
installModule, installModule,
marketForPort, marketForPort,
salvageForPirate, salvageForPirate,
@ -45,11 +46,15 @@ import {
import { MODULE_CATALOG } from './progression/modules.js'; import { MODULE_CATALOG } from './progression/modules.js';
import { import {
acceptMission, acceptMission,
bestTradeDestination,
buyTradeGood, buyTradeGood,
completeMission, completeMission,
marketAtPort, marketAtPort,
missionOffersAtPort, missionOffersAtPort,
missionStatus, missionStatus,
payForHullRepair,
recordRouteTradeEvent,
repairQuote,
sellTradeGood, sellTradeGood,
tradeCargoUnits, tradeCargoUnits,
} from './progression/dock-economy.js'; } from './progression/dock-economy.js';
@ -114,7 +119,7 @@ class GameScene extends Phaser.Scene {
height: WORLD_SIZE, height: WORLD_SIZE,
}); });
this.progression = createStarterProgression(); this.progression = createStarterProgression();
this.playerStats = deriveShipStats(this.progression.loadout); this.playerStats = deriveShipStats(this.progression.loadout, this.progression.installedConditions);
this.pendingMobileActions = new Set(); this.pendingMobileActions = new Set();
this.pendingActionDetails = new Map(); this.pendingActionDetails = new Map();
this.pendingModulePurchaseId = null; this.pendingModulePurchaseId = null;
@ -545,7 +550,7 @@ class GameScene extends Phaser.Scene {
} }
applyProgressionStats(previousMaxHull = this.playerStats?.maxHull || PLAYER_MAX_HULL) { applyProgressionStats(previousMaxHull = this.playerStats?.maxHull || PLAYER_MAX_HULL) {
this.playerStats = deriveShipStats(this.progression.loadout); this.playerStats = deriveShipStats(this.progression.loadout, this.progression.installedConditions);
this.playerBroadsideReloadMs = this.playerStats.reloadMs; this.playerBroadsideReloadMs = this.playerStats.reloadMs;
if (this.playerStats.maxHull > previousMaxHull) { if (this.playerStats.maxHull > previousMaxHull) {
@ -581,6 +586,7 @@ class GameScene extends Phaser.Scene {
const sellTradeAction = takeAction('sell-trade-good'); const sellTradeAction = takeAction('sell-trade-good');
const acceptMissionAction = takeAction('accept-mission'); const acceptMissionAction = takeAction('accept-mission');
const completeMissionAction = takeAction('complete-mission'); const completeMissionAction = takeAction('complete-mission');
const repairHullAction = takeAction('repair-hull');
const keyboardDock = Phaser.Input.Keyboard.JustDown(this.keys.buyUpgrade); const keyboardDock = Phaser.Input.Keyboard.JustDown(this.keys.buyUpgrade);
const keyboardEscape = Phaser.Input.Keyboard.JustDown(this.keys.closeShipyard); const keyboardEscape = Phaser.Input.Keyboard.JustDown(this.keys.closeShipyard);
@ -661,7 +667,8 @@ class GameScene extends Phaser.Scene {
if (!this.shipyardOpen || !docked) { if (!this.shipyardOpen || !docked) {
this.status.setText('FIT RECOVERED MODULES AT THE SHIPYARD'); this.status.setText('FIT RECOVERED MODULES AT THE SHIPYARD');
} else { } else {
const salvageEntry = this.progression.inventory?.[0]; const inventoryIndex = Math.max(0, Number(fitAction?.inventoryIndex) || 0);
const salvageEntry = this.progression.inventory?.[inventoryIndex];
const moduleId = typeof salvageEntry === 'string' const moduleId = typeof salvageEntry === 'string'
? salvageEntry ? salvageEntry
: salvageEntry?.moduleId; : salvageEntry?.moduleId;
@ -669,10 +676,11 @@ class GameScene extends Phaser.Scene {
this.status.setText('SALVAGE HOLD IS EMPTY'); this.status.setText('SALVAGE HOLD IS EMPTY');
} else { } else {
const previousMaxHull = this.playerStats.maxHull; const previousMaxHull = this.playerStats.maxHull;
this.progression = installModule(this.progression, moduleId); this.progression = installInventoryModule(this.progression, inventoryIndex);
this.applyProgressionStats(previousMaxHull); this.applyProgressionStats(previousMaxHull);
const module = MODULE_CATALOG[moduleId]; const module = MODULE_CATALOG[moduleId];
this.status.setText(`FITTED ${module?.name || moduleId}`); const condition = Math.round((this.progression.installedCondition || 1) * 100);
this.status.setText(`FITTED ${module?.name || moduleId} // ${condition}% CONDITION`);
} }
window.dispatchEvent(new CustomEvent('airship:shipyard-state', { window.dispatchEvent(new CustomEvent('airship:shipyard-state', {
detail: this.buildHudState(), detail: this.buildHudState(),
@ -685,7 +693,8 @@ class GameScene extends Phaser.Scene {
this.status.setText('SCRAP RECOVERED MODULES AT THE SHIPYARD'); this.status.setText('SCRAP RECOVERED MODULES AT THE SHIPYARD');
} else { } else {
const beforeScrap = this.progression.scrap || 0; const beforeScrap = this.progression.scrap || 0;
this.progression = scrapInventoryModule(this.progression, 0); const inventoryIndex = Math.max(0, Number(scrapAction?.inventoryIndex) || 0);
this.progression = scrapInventoryModule(this.progression, inventoryIndex);
const gained = Math.max(0, (this.progression.scrap || 0) - beforeScrap); const gained = Math.max(0, (this.progression.scrap || 0) - beforeScrap);
this.status.setText( this.status.setText(
this.progression.scrapped this.progression.scrapped
@ -699,7 +708,7 @@ class GameScene extends Phaser.Scene {
} }
if (this.dockOpen && docked && !this.shipyardOpen) { if (this.dockOpen && docked && !this.shipyardOpen) {
const market = marketAtPort(this.world, port); const market = marketAtPort(this.world, port, this.progression.economy);
if (buyTradeAction) { if (buyTradeAction) {
const item = market.find(({ goodId }) => goodId === buyTradeAction.goodId); const item = market.find(({ goodId }) => goodId === buyTradeAction.goodId);
@ -724,7 +733,12 @@ class GameScene extends Phaser.Scene {
} }
if (acceptMissionAction) { if (acceptMissionAction) {
const offers = missionOffersAtPort(this.world, port, this.defeated); const offers = missionOffersAtPort(
this.world,
port,
this.defeated,
this.progression.completedMissions || 0,
);
const mission = offers.find(({ id }) => id === acceptMissionAction.missionId); const mission = offers.find(({ id }) => id === acceptMissionAction.missionId);
this.progression = acceptMission( this.progression = acceptMission(
this.progression, this.progression,
@ -754,7 +768,25 @@ class GameScene extends Phaser.Scene {
); );
} }
if (buyTradeAction || sellTradeAction || acceptMissionAction || completeMissionAction) { if (repairHullAction) {
const result = payForHullRepair(
this.progression,
this.playerHull,
this.playerStats.maxHull,
port,
);
this.progression = result.progression;
this.playerHull = result.hull;
this.status.setText(
result.repaired
? `HULL REPAIRED // -¢${result.cost}`
: result.reason === 'full-hull'
? 'HULL ALREADY AT FULL STRENGTH'
: `REPAIR COST ¢${result.cost} // NOT ENOUGH COIN`,
);
}
if (buyTradeAction || sellTradeAction || acceptMissionAction || completeMissionAction || repairHullAction) {
window.dispatchEvent(new CustomEvent('airship:dock-state', { window.dispatchEvent(new CustomEvent('airship:dock-state', {
detail: this.buildHudState(), detail: this.buildHudState(),
})); }));
@ -1066,6 +1098,14 @@ class GameScene extends Phaser.Scene {
const range = Math.hypot(target.x - merchant.x, target.y - merchant.y); const range = Math.hypot(target.x - merchant.x, target.y - merchant.y);
if (range <= MERCHANT_PORT_ARRIVAL_RANGE) { if (range <= MERCHANT_PORT_ARRIVAL_RANGE) {
const destinationPortId = direction >= 0 ? route.toPortId : route.fromPortId;
this.progression = recordRouteTradeEvent(
this.progression,
this.world,
route,
destinationPortId,
{ kind: 'delivery' },
);
merchant.setData('routeDirection', -direction); merchant.setData('routeDirection', -direction);
merchant.setData('deliveries', (merchant.getData('deliveries') || 0) + 1); merchant.setData('deliveries', (merchant.getData('deliveries') || 0) + 1);
this.tradeDeliveries += 1; this.tradeDeliveries += 1;
@ -1154,10 +1194,29 @@ class GameScene extends Phaser.Scene {
const cargoValue = merchant.getData('cargoValue') || 50; const cargoValue = merchant.getData('cargoValue') || 50;
const x = merchant.x; const x = merchant.x;
const y = merchant.y; const y = merchant.y;
const route = this.world.tradeRoutes
.find(({ id }) => id === merchant.getData('routeId'));
const direction = merchant.getData('routeDirection') || 1;
const destinationPortId = direction >= 0 ? route?.toPortId : route?.fromPortId;
merchant.destroy(); merchant.destroy();
this.spawnExplosion(x, y, 1.05); this.spawnExplosion(x, y, 1.05);
this.tradeLosses += 1; this.tradeLosses += 1;
if (projectileOwner === 'player') this.playerTradeLosses += 1; if (projectileOwner === 'player') this.playerTradeLosses += 1;
if (route && destinationPortId) {
this.progression = recordRouteTradeEvent(
this.progression,
this.world,
route,
destinationPortId,
{ kind: 'loss', playerCaused: projectileOwner === 'player' },
);
}
if (projectileOwner === 'player') {
this.status.setText(
`MERCHANT DESTROYED // WANTED ${this.progression.economy?.wanted || 0}`,
);
}
const cargoSalvage = { const cargoSalvage = {
moduleId: null, moduleId: null,
@ -1380,7 +1439,7 @@ class GameScene extends Phaser.Scene {
updatePlayer(dt, deltaMs) { updatePlayer(dt, deltaMs) {
if (!this.player?.body) return; if (!this.player?.body) return;
const stats = this.playerStats || deriveShipStats(this.progression?.loadout); const stats = this.playerStats || deriveShipStats(this.progression?.loadout, this.progression?.installedConditions);
const rawHelmX = Math.abs(this.helmVector.x) > HELM_DEAD_ZONE ? this.helmVector.x : 0; const rawHelmX = Math.abs(this.helmVector.x) > HELM_DEAD_ZONE ? this.helmVector.x : 0;
const helmX = Math.sign(rawHelmX) * Math.pow(Math.abs(rawHelmX), HELM_STEER_CURVE); const helmX = Math.sign(rawHelmX) * Math.pow(Math.abs(rawHelmX), HELM_STEER_CURVE);
@ -1811,23 +1870,70 @@ class GameScene extends Phaser.Scene {
const marketUpgrade = docked const marketUpgrade = docked
? bestAffordableMarketUpgrade(this.progression, portState.port) ? bestAffordableMarketUpgrade(this.progression, portState.port)
: null; : null;
const salvageEntry = this.progression?.inventory?.[0] || null; const salvageItems = (this.progression?.inventory || [])
const salvageItem = describeInventoryEntry(salvageEntry); .map((entry, index) => {
const details = describeInventoryEntry(entry);
return details ? { ...details, inventoryIndex: index } : null;
})
.filter(Boolean);
const salvageItem = salvageItems[0] || null;
const upgradePaths = Object.fromEntries( const upgradePaths = Object.fromEntries(
['engine', 'hull', 'cargo', 'rudder'].map((slot) => [ ['engine', 'hull', 'cargo', 'rudder'].map((slot) => [
slot, slot,
upgradePathForSlot(this.progression.loadout, slot, marketStock), upgradePathForSlot(this.progression.loadout, slot, marketStock),
]), ]),
); );
const dockMarket = docked ? marketAtPort(this.world, portState.port) : []; const dockMarket = docked
? marketAtPort(this.world, portState.port, this.progression.economy)
: [];
const cargoUsed = tradeCargoUnits(this.progression); const cargoUsed = tradeCargoUnits(this.progression);
const missionOffers = docked const missionOffers = docked
? missionOffersAtPort(this.world, portState.port, this.defeated) ? missionOffersAtPort(
this.world,
portState.port,
this.defeated,
this.progression.completedMissions || 0,
)
: []; : [];
const activeMission = missionStatus(this.progression, { const activeMission = missionStatus(this.progression, {
portId: docked ? portState.port?.id : null, portId: docked ? portState.port?.id : null,
defeated: this.defeated, defeated: this.defeated,
}); });
const repair = docked
? repairQuote(
this.playerHull,
this.playerStats.maxHull,
portState.port,
this.progression.economy?.wanted || 0,
)
: null;
let objective = null;
if (activeMission?.type === 'delivery') {
const targetPort = this.world.ports
.find(({ id }) => id === activeMission.targetPortId);
if (targetPort) {
const objectiveBearing = bearingTo(player, targetPort);
objective = {
type: 'delivery',
label: targetPort.name,
distance: Math.hypot(targetPort.x - player.x, targetPort.y - player.y),
bearing: objectiveBearing,
relativeBearing: signedAngleDelta(objectiveBearing, normalizeDegrees(player.angle)),
progressText: activeMission.progressText,
ready: activeMission.ready,
};
}
} else if (activeMission?.type === 'bounty') {
objective = {
type: 'bounty',
label: 'PIRATE BOUNTY',
progressText: activeMission.progressText,
kills: activeMission.kills || 0,
targetKills: activeMission.targetKills,
ready: activeMission.ready,
};
}
return { return {
gameState: this.gameState, gameState: this.gameState,
@ -1851,6 +1957,7 @@ class GameScene extends Phaser.Scene {
port: reloadReady('port'), port: reloadReady('port'),
starboard: reloadReady('starboard'), starboard: reloadReady('starboard'),
}, },
objective,
environment: { environment: {
windSpeed: this.environmentWind?.strength || 0, windSpeed: this.environmentWind?.strength || 0,
windHeading: this.environmentWind?.heading || 0, windHeading: this.environmentWind?.heading || 0,
@ -1873,9 +1980,16 @@ class GameScene extends Phaser.Scene {
cargoCapacity: this.playerStats?.cargoCapacity || 0, cargoCapacity: this.playerStats?.cargoCapacity || 0,
market: dockMarket.map((item) => { market: dockMarket.map((item) => {
const held = this.progression.tradeCargo?.[item.goodId] || 0; const held = this.progression.tradeCargo?.[item.goodId] || 0;
const opportunity = bestTradeDestination(
this.world,
portState.port,
item.goodId,
this.progression.economy,
);
return { return {
...item, ...item,
held, held,
opportunity,
canBuy: (this.progression.credits || 0) >= item.buyPrice canBuy: (this.progression.credits || 0) >= item.buyPrice
&& cargoUsed < (this.playerStats?.cargoCapacity || 0), && cargoUsed < (this.playerStats?.cargoCapacity || 0),
canSell: held > 0, canSell: held > 0,
@ -1883,6 +1997,9 @@ class GameScene extends Phaser.Scene {
}), }),
missionOffers, missionOffers,
activeMission, activeMission,
repair,
wanted: this.progression.economy?.wanted || 0,
reputation: this.progression.economy?.reputation || 0,
}, },
progression: { progression: {
credits: this.progression?.credits || 0, credits: this.progression?.credits || 0,
@ -1895,6 +2012,7 @@ class GameScene extends Phaser.Scene {
shipyardOpen: this.shipyardOpen, shipyardOpen: this.shipyardOpen,
shipStats: { ...this.playerStats }, shipStats: { ...this.playerStats },
loadout: { ...this.progression.loadout }, loadout: { ...this.progression.loadout },
installedConditions: { ...(this.progression.installedConditions || {}) },
marketStock: marketStock.map((module) => ({ marketStock: marketStock.map((module) => ({
id: module.id, id: module.id,
name: module.name, name: module.name,
@ -1909,6 +2027,7 @@ class GameScene extends Phaser.Scene {
name: marketUpgrade.name, name: marketUpgrade.name,
price: marketUpgrade.price, price: marketUpgrade.price,
} : null, } : null,
salvageItems,
salvageItem, salvageItem,
salvageModule: salvageItem ? { salvageModule: salvageItem ? {
id: salvageItem.moduleId, id: salvageItem.moduleId,
@ -1998,9 +2117,14 @@ class GameScene extends Phaser.Scene {
this.speedText.setText(`${Math.abs(state.speed || 0).toFixed(0)} ${state.speed < 0 ? 'REV' : 'FWD'}`); this.speedText.setText(`${Math.abs(state.speed || 0).toFixed(0)} ${state.speed < 0 ? 'REV' : 'FWD'}`);
this.headingText.setText(`${(state.heading || 0).toFixed(0).padStart(3, '0')}°`); this.headingText.setText(`${(state.heading || 0).toFixed(0).padStart(3, '0')}°`);
this.waveText.setText(`WAVE ${state.wave} • ${state.hostiles} HOSTILE${state.hostiles === 1 ? '' : 'S'}`); this.waveText.setText(`WAVE ${state.wave} • ${state.hostiles} HOSTILE${state.hostiles === 1 ? '' : 'S'}`);
const objectiveText = state.objective?.type === 'delivery'
? `${state.objective.label} ${Math.round(state.objective.distance || 0)}m`
: state.objective?.type === 'bounty'
? state.objective.progressText
: null;
this.combatText.setText(compact this.combatText.setText(compact
? `K ${state.kills}\nT${state.trade?.activeMerchants || 0}` ? `K ${state.kills}\nT${state.trade?.activeMerchants || 0}${objectiveText ? `\n${objectiveText}` : ''}`
: `DEFEATED ${state.kills}\nRANGE ${rangeText}\nTRADE ${state.trade?.activeMerchants || 0} LOSS ${state.trade?.losses || 0}\n¢${state.progression?.credits || 0} S${state.progression?.scrap || 0}`); : `DEFEATED ${state.kills}\nRANGE ${rangeText}\nTRADE ${state.trade?.activeMerchants || 0} LOSS ${state.trade?.losses || 0}\n¢${state.progression?.credits || 0} S${state.progression?.scrap || 0}${objectiveText ? `\nMISSION ${objectiveText}` : ''}`);
this.playerHullLabel.setText(`SPARROW ${playerHull}/${playerMaxHull}`); this.playerHullLabel.setText(`SPARROW ${playerHull}/${playerMaxHull}`);
this.enemyHullLabel.setText(target this.enemyHullLabel.setText(target

View File

@ -326,6 +326,42 @@ if (!isDesktopPointer.matches) {
display: block; display: block;
} }
.steam-objective {
display: none;
position: absolute;
left: 50%;
top: 20.5%;
transform: translateX(-50%);
align-items: center;
gap: 7px;
max-width: 78%;
padding: 5px 9px;
border: 1px solid rgba(201, 146, 60, .58);
border-radius: 999px;
background: rgba(5, 22, 28, .82);
color: #f7dfa1;
font: 700 clamp(9px, 2.45vw, 12px)/1.1 system-ui, sans-serif;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
box-shadow: 0 2px 10px rgba(0,0,0,.28);
}
.steam-objective.visible { display: flex; }
.steam-objective-arrow {
display: inline-block;
color: #ffe08c;
font-size: 18px;
line-height: 1;
transform-origin: center;
}
.steam-objective-text {
overflow: hidden;
text-overflow: ellipsis;
}
#mobile-controls.controller-active #drag-stick { #mobile-controls.controller-active #drag-stick {
left: 1%; left: 1%;
bottom: max(8px, env(safe-area-inset-bottom)); bottom: max(8px, env(safe-area-inset-bottom));
@ -697,6 +733,14 @@ if (!isDesktopPointer.matches) {
status.dataset.steam = 'status'; status.dataset.steam = 'status';
hud.appendChild(status); hud.appendChild(status);
const objective = document.createElement('div');
objective.className = 'steam-objective';
objective.innerHTML = `
<span class="steam-objective-arrow" data-steam="objective-arrow">↑</span>
<span class="steam-objective-text" data-steam="objective-text"></span>
`;
hud.appendChild(objective);
const fields = Object.fromEntries( const fields = Object.fromEntries(
[...hud.querySelectorAll('[data-steam]')].map((el) => [el.dataset.steam, el]), [...hud.querySelectorAll('[data-steam]')].map((el) => [el.dataset.steam, el]),
); );
@ -800,6 +844,21 @@ if (!isDesktopPointer.matches) {
fields['port-reload'].style.transform = `scaleX(${clamp01(state.reload?.port ?? 1)})`; fields['port-reload'].style.transform = `scaleX(${clamp01(state.reload?.port ?? 1)})`;
fields['starboard-reload'].style.transform = `scaleX(${clamp01(state.reload?.starboard ?? 1)})`; fields['starboard-reload'].style.transform = `scaleX(${clamp01(state.reload?.starboard ?? 1)})`;
const mission = state.objective || null;
objective.classList.toggle('visible', Boolean(mission));
if (mission?.type === 'delivery') {
fields['objective-arrow'].hidden = false;
fields['objective-arrow'].style.transform = `rotate(${mission.relativeBearing || 0}deg)`;
fields['objective-text'].textContent =
`${mission.label} • ${Math.round(mission.distance || 0)}m`;
} else if (mission?.type === 'bounty') {
fields['objective-arrow'].hidden = true;
fields['objective-text'].textContent = mission.progressText || 'PIRATE BOUNTY';
} else {
fields['objective-arrow'].hidden = true;
fields['objective-text'].textContent = '';
}
const progression = state.progression || {}; const progression = state.progression || {};
if (buyButton) { if (buyButton) {
buyButton.hidden = !progression.docked; buyButton.hidden = !progression.docked;

View File

@ -23,8 +23,12 @@ const resourceToGood = (resource) => resource === 'food'
? 'ore' ? 'ore'
: resource === 'fuel' : resource === 'fuel'
? 'fuel' ? 'fuel'
: resource === 'timber'
? 'machinery'
: resource; : resource;
const shortageKey = (portId, goodId) => `${portId}:${goodId}`;
export const tradeCargoUnits = (progression = {}) => { export const tradeCargoUnits = (progression = {}) => {
const cargo = progression.tradeCargo || {}; const cargo = progression.tradeCargo || {};
const goods = Object.values(cargo).reduce((sum, qty) => sum + Math.max(0, Number(qty) || 0), 0); const goods = Object.values(cargo).reduce((sum, qty) => sum + Math.max(0, Number(qty) || 0), 0);
@ -34,11 +38,14 @@ export const tradeCargoUnits = (progression = {}) => {
return goods + missionUnits; return goods + missionUnits;
}; };
export const marketAtPort = (world, port) => { export const marketAtPort = (world, port, economy = {}) => {
if (!port) return []; if (!port) return [];
const typeMods = TYPE_MODIFIERS[port.type] || TYPE_MODIFIERS.trade; const typeMods = TYPE_MODIFIERS[port.type] || TYPE_MODIFIERS.trade;
const exports = new Set((port.exports || []).map(resourceToGood)); const exports = new Set((port.exports || []).map(resourceToGood));
const imports = new Set((port.imports || []).map(resourceToGood)); const imports = new Set((port.imports || []).map(resourceToGood));
const wanted = clamp(Number(economy.wanted) || 0, 0, 8);
const reputation = Number(economy.reputation) || 0;
const piracyMarkup = 1 + wanted * 0.035 + Math.max(0, -reputation) * 0.004;
return Object.values(TRADE_GOODS).map((good) => { return Object.values(TRADE_GOODS).map((good) => {
const random = createSeededRandom(`${world?.seed || 'world'}:${port.id}:${good.id}:market`); const random = createSeededRandom(`${world?.seed || 'world'}:${port.id}:${good.id}:market`);
@ -48,6 +55,10 @@ export const marketAtPort = (world, port) => {
if (imports.has(good.id)) modifier *= 1.2; if (imports.has(good.id)) modifier *= 1.2;
modifier *= 0.96 + (1 - (port.wealth || 0.5)) * 0.08; modifier *= 0.96 + (1 - (port.wealth || 0.5)) * 0.08;
const shortage = clamp(Number(economy.shortages?.[shortageKey(port.id, good.id)]) || 0, 0, 6);
modifier *= 1 + shortage * 0.08;
modifier *= piracyMarkup;
const localPrice = Math.max(8, Math.round(good.basePrice * modifier * jitter)); const localPrice = Math.max(8, Math.round(good.basePrice * modifier * jitter));
return { return {
goodId: good.id, goodId: good.id,
@ -56,10 +67,35 @@ export const marketAtPort = (world, port) => {
sellPrice: Math.max(5, Math.round(localPrice * 0.86)), sellPrice: Math.max(5, Math.round(localPrice * 0.86)),
exported: exports.has(good.id), exported: exports.has(good.id),
imported: imports.has(good.id), imported: imports.has(good.id),
shortage,
}; };
}); });
}; };
export const bestTradeDestination = (world, originPort, goodId, economy = {}) => {
if (!originPort || !goodId) return null;
const originItem = marketAtPort(world, originPort, economy)
.find((item) => item.goodId === goodId);
if (!originItem) return null;
const candidates = (world?.ports || [])
.filter((port) => port.id !== originPort.id)
.map((port) => {
const item = marketAtPort(world, port, economy)
.find((entry) => entry.goodId === goodId);
return item ? {
portId: port.id,
portName: port.name,
sellPrice: item.sellPrice,
margin: item.sellPrice - originItem.buyPrice,
} : null;
})
.filter(Boolean)
.sort((a, b) => b.margin - a.margin || b.sellPrice - a.sellPrice);
return candidates[0] || null;
};
export const buyTradeGood = (progression, marketItem, stats, quantity = 1) => { export const buyTradeGood = (progression, marketItem, stats, quantity = 1) => {
const qty = Math.max(1, Math.floor(quantity)); const qty = Math.max(1, Math.floor(quantity));
const price = Math.max(0, marketItem?.buyPrice || 0) * qty; const price = Math.max(0, marketItem?.buyPrice || 0) * qty;
@ -111,15 +147,17 @@ const otherPortId = (route, portId) => route.fromPortId === portId
? route.toPortId ? route.toPortId
: route.fromPortId; : route.fromPortId;
export const missionOffersAtPort = (world, port, defeated = 0) => { export const missionOffersAtPort = (world, port, defeated = 0, cycle = 0) => {
if (!port) return []; if (!port) return [];
const random = createSeededRandom(`${world?.seed || 'world'}:${port.id}:missions:${cycle}`);
const routes = connectedRoutes(world, port.id) const routes = connectedRoutes(world, port.id)
.sort((a, b) => b.value - a.value || routeDistance(b) - routeDistance(a)); .sort((a, b) => b.value - a.value || routeDistance(b) - routeDistance(a));
const deliveryRoute = routes[0] || null; const deliveryRoute = routes.length
? routes[Math.floor(random() * routes.length)]
: null;
const targetPort = deliveryRoute const targetPort = deliveryRoute
? world.ports.find(({ id }) => id === otherPortId(deliveryRoute, port.id)) ? world.ports.find(({ id }) => id === otherPortId(deliveryRoute, port.id))
: null; : null;
const random = createSeededRandom(`${world?.seed || 'world'}:${port.id}:missions`);
const offers = []; const offers = [];
if (deliveryRoute && targetPort) { if (deliveryRoute && targetPort) {
@ -127,7 +165,7 @@ export const missionOffersAtPort = (world, port, defeated = 0) => {
const distance = routeDistance(deliveryRoute); const distance = routeDistance(deliveryRoute);
const reward = Math.round(120 + distance * 0.11 + deliveryRoute.value * 160 + cargoUnits * 30); const reward = Math.round(120 + distance * 0.11 + deliveryRoute.value * 160 + cargoUnits * 30);
offers.push({ offers.push({
id: `delivery:${port.id}:${targetPort.id}`, id: `delivery:${cycle}:${port.id}:${targetPort.id}`,
type: 'delivery', type: 'delivery',
title: `Courier Run → ${targetPort.name}`, title: `Courier Run → ${targetPort.name}`,
description: `Carry ${cargoUnits} sealed cargo unit${cargoUnits === 1 ? '' : 's'} to ${targetPort.name}.`, description: `Carry ${cargoUnits} sealed cargo unit${cargoUnits === 1 ? '' : 's'} to ${targetPort.name}.`,
@ -137,12 +175,13 @@ export const missionOffersAtPort = (world, port, defeated = 0) => {
targetPortName: targetPort.name, targetPortName: targetPort.name,
cargoUnits, cargoUnits,
reward, reward,
boardCycle: cycle,
}); });
} }
const killTarget = 2 + Math.floor(random() * 3); const killTarget = 2 + Math.floor(random() * 3);
offers.push({ offers.push({
id: `bounty:${port.id}:${killTarget}`, id: `bounty:${cycle}:${port.id}:${killTarget}`,
type: 'bounty', type: 'bounty',
title: `Pirate Bounty ×${killTarget}`, title: `Pirate Bounty ×${killTarget}`,
description: `Destroy ${killTarget} pirate airship${killTarget === 1 ? '' : 's'} and claim the bounty at any dock.`, description: `Destroy ${killTarget} pirate airship${killTarget === 1 ? '' : 's'} and claim the bounty at any dock.`,
@ -151,6 +190,7 @@ export const missionOffersAtPort = (world, port, defeated = 0) => {
targetKills: killTarget, targetKills: killTarget,
reward: 150 + killTarget * 90, reward: 150 + killTarget * 90,
baselineKills: defeated, baselineKills: defeated,
boardCycle: cycle,
}); });
return offers; return offers;
@ -204,7 +244,96 @@ export const completeMission = (progression, context = {}) => {
...progression, ...progression,
credits: (progression.credits || 0) + Math.max(0, status.reward || 0), credits: (progression.credits || 0) + Math.max(0, status.reward || 0),
activeMission: null, activeMission: null,
completedMissions: (progression.completedMissions || 0) + 1,
missionCompleted: true, missionCompleted: true,
lastCompletedMission: status, lastCompletedMission: status,
}; };
}; };
const routeTradeGood = (world, route, destinationPortId) => {
const destination = world?.ports?.find(({ id }) => id === destinationPortId);
const sourceId = route?.fromPortId === destinationPortId ? route?.toPortId : route?.fromPortId;
const source = world?.ports?.find(({ id }) => id === sourceId);
const desired = (destination?.imports || []).map(resourceToGood)
.find((goodId) => TRADE_GOODS[goodId]);
if (desired) return desired;
const exported = (source?.exports || []).map(resourceToGood)
.find((goodId) => TRADE_GOODS[goodId]);
return exported || 'provisions';
};
export const recordRouteTradeEvent = (
progression,
world,
route,
destinationPortId,
{ kind = 'delivery', playerCaused = false } = {},
) => {
if (!route || !destinationPortId) return progression;
const goodId = routeTradeGood(world, route, destinationPortId);
const economy = {
shortages: { ...(progression.economy?.shortages || {}) },
reputation: Number(progression.economy?.reputation) || 0,
wanted: Number(progression.economy?.wanted) || 0,
};
const key = shortageKey(destinationPortId, goodId);
const current = Number(economy.shortages[key]) || 0;
economy.shortages[key] = kind === 'loss'
? clamp(current + 1.25, 0, 6)
: clamp(current - 0.7, 0, 6);
if (playerCaused) {
economy.reputation = Math.max(-20, economy.reputation - 2);
economy.wanted = clamp(economy.wanted + 1, 0, 8);
}
return {
...progression,
economy,
lastTradeEvent: {
kind,
routeId: route.id,
destinationPortId,
goodId,
playerCaused,
},
};
};
export const repairQuote = (currentHull, maxHull, port, wanted = 0) => {
const missing = Math.max(0, Math.ceil((maxHull || 0) - (currentHull || 0)));
const baseRate = port?.type === 'capital' ? 2
: port?.type === 'industrial' ? 2.2
: port?.type === 'trade' ? 2.45
: 2.7;
const rate = baseRate * (1 + clamp(Number(wanted) || 0, 0, 8) * 0.06);
return {
missing,
cost: Math.ceil(missing * rate),
rate,
};
};
export const payForHullRepair = (progression, currentHull, maxHull, port) => {
const quote = repairQuote(
currentHull,
maxHull,
port,
progression.economy?.wanted || 0,
);
if (quote.missing <= 0) {
return { progression, hull: currentHull, repaired: false, reason: 'full-hull', cost: 0 };
}
if ((progression.credits || 0) < quote.cost) {
return { progression, hull: currentHull, repaired: false, reason: 'insufficient-credits', cost: quote.cost };
}
return {
progression: {
...progression,
credits: progression.credits - quote.cost,
},
hull: maxHull,
repaired: true,
cost: quote.cost,
};
};

View File

@ -17,20 +17,29 @@ export const createStarterProgression = () => ({
credits: 650, credits: 650,
scrap: 0, scrap: 0,
loadout: { ...STARTER_LOADOUT }, loadout: { ...STARTER_LOADOUT },
installedConditions: {},
inventory: [], inventory: [],
tradeCargo: {}, tradeCargo: {},
activeMission: null, activeMission: null,
completedMissions: 0,
economy: {
shortages: {},
reputation: 0,
wanted: 0,
},
}); });
export const deriveShipStats = (loadout = STARTER_LOADOUT) => { export const deriveShipStats = (loadout = STARTER_LOADOUT, installedConditions = {}) => {
const stats = { ...SHIP_BASE_STATS }; const stats = { ...SHIP_BASE_STATS };
for (const moduleId of Object.values(loadout)) { for (const [slot, moduleId] of Object.entries(loadout)) {
if (!moduleId) continue; if (!moduleId) continue;
const module = MODULE_CATALOG[moduleId]; const module = MODULE_CATALOG[moduleId];
if (!module) continue; if (!module) continue;
const condition = clamp(Number(installedConditions?.[slot] ?? 1), 0.05, 1);
const effectiveness = 0.55 + condition * 0.45;
for (const [key, value] of Object.entries(module.modifiers || {})) { for (const [key, value] of Object.entries(module.modifiers || {})) {
stats[key] = (stats[key] ?? 0) + value; stats[key] = (stats[key] ?? 0) + value * effectiveness;
} }
} }
@ -103,7 +112,7 @@ export const describeInventoryEntry = (entry) => {
}; };
}; };
export const installModule = (progression, moduleId) => { export const installModule = (progression, moduleId, condition = 1) => {
const module = MODULE_CATALOG[moduleId]; const module = MODULE_CATALOG[moduleId];
if (!module) return { ...progression, installed: false, reason: 'unknown-module' }; if (!module) return { ...progression, installed: false, reason: 'unknown-module' };
if (progression.loadout?.[module.slot] === moduleId) { if (progression.loadout?.[module.slot] === moduleId) {
@ -115,22 +124,71 @@ export const installModule = (progression, moduleId) => {
const installedEntry = inventoryIndex >= 0 ? inventory[inventoryIndex] : null; const installedEntry = inventoryIndex >= 0 ? inventory[inventoryIndex] : null;
if (inventoryIndex >= 0) inventory.splice(inventoryIndex, 1); if (inventoryIndex >= 0) inventory.splice(inventoryIndex, 1);
const loadout = { ...(progression.loadout || STARTER_LOADOUT) }; const loadoutState = { ...(progression.loadout || STARTER_LOADOUT) };
const replaced = loadout[module.slot] || null; const installedConditions = { ...(progression.installedConditions || {}) };
loadout[module.slot] = moduleId; const replaced = loadoutState[module.slot] || null;
const replacedCondition = installedConditions[module.slot] ?? 1;
loadoutState[module.slot] = moduleId;
installedConditions[module.slot] = installedEntry
? inventoryCondition(installedEntry)
: clamp(Number(condition) || 1, 0.05, 1);
if (replaced && MODULE_CATALOG[replaced]?.price > 0) { if (replaced && MODULE_CATALOG[replaced]?.price > 0) {
inventory.push({ moduleId: replaced, condition: 1, source: 'removed' }); inventory.push({
moduleId: replaced,
condition: replacedCondition,
source: 'removed',
});
} }
return { return {
...progression, ...progression,
loadout, loadout: loadoutState,
installedConditions,
inventory, inventory,
installed: true, installed: true,
replaced, replaced,
installedModule: moduleId, installedModule: moduleId,
installedCondition: installedEntry ? inventoryCondition(installedEntry) : 1, installedCondition: installedConditions[module.slot],
};
};
export const installInventoryModule = (progression, index = 0) => {
const inventory = [...(progression.inventory || [])];
const entry = inventory[index];
const moduleId = inventoryModuleId(entry);
const module = MODULE_CATALOG[moduleId];
if (!entry || !module) return { ...progression, installed: false, reason: 'empty-hold' };
if (progression.loadout?.[module.slot] === moduleId) {
return { ...progression, installed: false, reason: 'already-installed' };
}
inventory.splice(index, 1);
const loadoutState = { ...(progression.loadout || STARTER_LOADOUT) };
const installedConditions = { ...(progression.installedConditions || {}) };
const replaced = loadoutState[module.slot] || null;
const replacedCondition = installedConditions[module.slot] ?? 1;
loadoutState[module.slot] = moduleId;
installedConditions[module.slot] = inventoryCondition(entry);
if (replaced && MODULE_CATALOG[replaced]?.price > 0) {
inventory.push({
moduleId: replaced,
condition: replacedCondition,
source: 'removed',
});
}
return {
...progression,
loadout: loadoutState,
installedConditions,
inventory,
installed: true,
replaced,
installedModule: moduleId,
installedCondition: installedConditions[module.slot],
}; };
}; };
@ -151,10 +209,15 @@ export const buyAndInstallModule = (progression, moduleId) => {
} }
const loadout = { ...(progression.loadout || STARTER_LOADOUT), [module.slot]: moduleId }; const loadout = { ...(progression.loadout || STARTER_LOADOUT), [module.slot]: moduleId };
const installedConditions = {
...(progression.installedConditions || {}),
[module.slot]: 1,
};
return { return {
...progression, ...progression,
credits: progression.credits - module.price, credits: progression.credits - module.price,
loadout, loadout,
installedConditions,
purchased: true, purchased: true,
installed: true, installed: true,
installedModule: moduleId, installedModule: moduleId,
@ -205,7 +268,11 @@ export const salvageForPirate = ({
}; };
}; };
export const collectSalvage = (progression, salvage, stats = deriveShipStats(progression.loadout)) => { export const collectSalvage = (
progression,
salvage,
stats = deriveShipStats(progression.loadout, progression.installedConditions),
) => {
const inventory = [...(progression.inventory || [])]; const inventory = [...(progression.inventory || [])];
let moduleCollected = false; let moduleCollected = false;
@ -244,7 +311,7 @@ export const scrapInventoryModule = (progression, index = 0) => {
}; };
export const bestAffordableMarketUpgrade = (progression, port) => { export const bestAffordableMarketUpgrade = (progression, port) => {
const stats = deriveShipStats(progression.loadout); const stats = deriveShipStats(progression.loadout, progression.installedConditions);
const candidates = marketForPort(port) const candidates = marketForPort(port)
.filter((module) => { .filter((module) => {
if (!Number.isInteger(module.tier)) { if (!Number.isInteger(module.tier)) {
@ -256,7 +323,10 @@ export const bestAffordableMarketUpgrade = (progression, port) => {
}) })
.filter((module) => module.price <= (progression.credits || 0)) .filter((module) => module.price <= (progression.credits || 0))
.map((module) => { .map((module) => {
const next = deriveShipStats({ ...progression.loadout, [module.slot]: module.id }); const next = deriveShipStats(
{ ...progression.loadout, [module.slot]: module.id },
{ ...(progression.installedConditions || {}), [module.slot]: 1 },
);
const score = (next.maxHull - stats.maxHull) * 0.7 const score = (next.maxHull - stats.maxHull) * 0.7
+ (next.maxSpeed - stats.maxSpeed) + (next.maxSpeed - stats.maxSpeed)
+ (next.acceleration - stats.acceleration) * 0.45 + (next.acceleration - stats.acceleration) * 0.45

View File

@ -79,12 +79,14 @@ overlay.innerHTML = `
<footer class="shipyard-footer"> <footer class="shipyard-footer">
<div class="shipyard-salvage-panel"> <div class="shipyard-salvage-panel">
<div class="shipyard-salvage-copy"> <div class="shipyard-salvage-copy">
<strong>SALVAGE HOLD</strong> <strong>SALVAGE HOLD <span data-shipyard="salvage-index">0/0</span></strong>
<span data-shipyard="salvage-name">EMPTY</span> <span data-shipyard="salvage-name">EMPTY</span>
<span data-shipyard="salvage-meta">Pirate wrecks repair hull immediately.</span> <span data-shipyard="salvage-meta">Pirate wrecks repair hull immediately.</span>
<span data-shipyard="salvage-effect">Recovered modules are stored here to fit or scrap.</span> <span data-shipyard="salvage-effect">Recovered modules are stored here to fit or scrap.</span>
</div> </div>
<div class="shipyard-salvage-actions"> <div class="shipyard-salvage-actions">
<button class="shipyard-salvage" type="button" data-shipyard="prev">◀ PREV</button>
<button class="shipyard-salvage" type="button" data-shipyard="next">NEXT ▶</button>
<button class="shipyard-salvage" type="button" data-shipyard="fit">FIT MODULE</button> <button class="shipyard-salvage" type="button" data-shipyard="fit">FIT MODULE</button>
<button class="shipyard-salvage shipyard-scrap" type="button" data-shipyard="scrap">SCRAP MODULE</button> <button class="shipyard-salvage shipyard-scrap" type="button" data-shipyard="scrap">SCRAP MODULE</button>
</div> </div>
@ -477,9 +479,12 @@ const fields = Object.fromEntries(
const closeButton = overlay.querySelector('.shipyard-close'); const closeButton = overlay.querySelector('.shipyard-close');
const fitButton = overlay.querySelector('[data-shipyard="fit"]'); const fitButton = overlay.querySelector('[data-shipyard="fit"]');
const scrapButton = overlay.querySelector('[data-shipyard="scrap"]'); const scrapButton = overlay.querySelector('[data-shipyard="scrap"]');
const prevButton = overlay.querySelector('[data-shipyard="prev"]');
const nextButton = overlay.querySelector('[data-shipyard="next"]');
const upgradeButtons = [...overlay.querySelectorAll('.shipyard-upgrade-button')]; const upgradeButtons = [...overlay.querySelectorAll('.shipyard-upgrade-button')];
let latestState = null; let latestState = null;
let selectedSalvageIndex = 0;
const render = (state) => { const render = (state) => {
if (!state?.progression) return; if (!state?.progression) return;
@ -538,13 +543,24 @@ const render = (state) => {
); );
} }
const salvage = progression.salvageItem; const salvageItems = progression.salvageItems || [];
selectedSalvageIndex = salvageItems.length
? Math.min(selectedSalvageIndex, salvageItems.length - 1)
: 0;
const salvage = salvageItems[selectedSalvageIndex] || null;
fields['salvage-index'].textContent = salvageItems.length
? `${selectedSalvageIndex + 1}/${salvageItems.length}`
: '0/0';
prevButton.disabled = salvageItems.length <= 1;
nextButton.disabled = salvageItems.length <= 1;
if (salvage) { if (salvage) {
const effectiveness = Math.round(55 + salvage.condition * 45);
fields['salvage-name'].textContent = salvage.name; fields['salvage-name'].textContent = salvage.name;
fields['salvage-meta'].textContent = fields['salvage-meta'].textContent =
`${String(salvage.slot || 'module').toUpperCase()} • ${salvage.conditionPercent}% CONDITION`; `${String(salvage.slot || 'module').toUpperCase()} • ${salvage.conditionPercent}% CONDITION • ${effectiveness}% EFFECT`;
fields['salvage-effect'].textContent = fields['salvage-effect'].textContent =
`${salvage.effectSummary || 'Recovered equipment'} • Fit it or convert it to scrap.`; `${salvage.effectSummary || 'Recovered equipment'} • Fit this module or convert it to scrap.`;
fitButton.disabled = false; fitButton.disabled = false;
fitButton.textContent = 'FIT MODULE'; fitButton.textContent = 'FIT MODULE';
scrapButton.disabled = false; scrapButton.disabled = false;
@ -592,22 +608,37 @@ for (const button of upgradeButtons) {
}); });
} }
prevButton.addEventListener('click', () => {
const items = latestState?.progression?.salvageItems || [];
if (items.length <= 1) return;
selectedSalvageIndex = (selectedSalvageIndex - 1 + items.length) % items.length;
render(latestState);
});
nextButton.addEventListener('click', () => {
const items = latestState?.progression?.salvageItems || [];
if (items.length <= 1) return;
selectedSalvageIndex = (selectedSalvageIndex + 1) % items.length;
render(latestState);
});
fitButton.addEventListener('click', () => { fitButton.addEventListener('click', () => {
if (fitButton.disabled) return; if (fitButton.disabled) return;
dispatchAction('fit-salvage'); dispatchAction('fit-salvage', { inventoryIndex: selectedSalvageIndex });
}); });
scrapButton.addEventListener('click', () => { scrapButton.addEventListener('click', () => {
if (scrapButton.disabled) return; if (scrapButton.disabled) return;
dispatchAction('scrap-salvage'); dispatchAction('scrap-salvage', { inventoryIndex: selectedSalvageIndex });
}); });
window.addEventListener('airship:shipyard-open', (event) => show(event.detail)); window.addEventListener('airship:shipyard-open', (event) => show(event.detail));
window.addEventListener('airship:shipyard-state', (event) => render(event.detail)); window.addEventListener('airship:shipyard-state', (event) => render(event.detail));
window.addEventListener('airship:shipyard-close', hide); window.addEventListener('airship:shipyard-close', hide);
window.addEventListener('airship:hud-state', (event) => { window.addEventListener('airship:hud-state', (event) => {
// Shipyard buttons must remain stable during touch gestures.
// Explicit shipyard-state events redraw after purchases/fits/scraps.
latestState = event.detail; latestState = event.detail;
if (!overlay.hidden) render(event.detail);
}); });
window.addEventListener('keydown', (event) => { window.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && !overlay.hidden) { if (event.key === 'Escape' && !overlay.hidden) {

View File

@ -5,11 +5,14 @@ import { generateWorld } from '../src/world/world-generator.js';
import { createStarterProgression, deriveShipStats } from '../src/progression/ship-loadout.js'; import { createStarterProgression, deriveShipStats } from '../src/progression/ship-loadout.js';
import { import {
acceptMission, acceptMission,
bestTradeDestination,
buyTradeGood, buyTradeGood,
completeMission, completeMission,
marketAtPort, marketAtPort,
missionOffersAtPort, missionOffersAtPort,
missionStatus, missionStatus,
payForHullRepair,
recordRouteTradeEvent,
sellTradeGood, sellTradeGood,
tradeCargoUnits, tradeCargoUnits,
} from '../src/progression/dock-economy.js'; } from '../src/progression/dock-economy.js';
@ -85,3 +88,59 @@ test('bounty missions advance from actual pirate kill count', () => {
}); });
assert.equal(ready.ready, true); assert.equal(ready.ready, true);
}); });
test('mission boards rotate after completed contracts', () => {
const world = generateWorld({ seed: 'DOCK-ROTATE' });
const port = world.ports[0];
const first = missionOffersAtPort(world, port, 0, 0);
const next = missionOffersAtPort(world, port, 0, 1);
assert.notDeepEqual(first.map(({ id }) => id), next.map(({ id }) => id));
});
test('merchant losses create shortages and player piracy adds wanted level', () => {
const world = generateWorld({ seed: 'DOCK-SHORTAGE' });
const route = world.tradeRoutes[0];
const destinationPortId = route.toPortId;
const starter = createStarterProgression();
const afterLoss = recordRouteTradeEvent(
starter,
world,
route,
destinationPortId,
{ kind: 'loss', playerCaused: true },
);
assert.equal(afterLoss.economy.wanted, 1);
assert.ok(afterLoss.economy.reputation < 0);
assert.ok(Object.values(afterLoss.economy.shortages).some((value) => value > 0));
const afterDelivery = recordRouteTradeEvent(
afterLoss,
world,
route,
destinationPortId,
{ kind: 'delivery' },
);
const before = Math.max(...Object.values(afterLoss.economy.shortages));
const after = Math.max(...Object.values(afterDelivery.economy.shortages));
assert.ok(after < before);
});
test('trade hints identify a best destination and real margin', () => {
const world = generateWorld({ seed: 'DOCK-HINTS' });
const port = world.ports[0];
const item = marketAtPort(world, port)[0];
const opportunity = bestTradeDestination(world, port, item.goodId);
assert.ok(opportunity);
assert.ok(Number.isFinite(opportunity.margin));
assert.notEqual(opportunity.portId, port.id);
});
test('dock repair restores hull when the player can afford it', () => {
const world = generateWorld({ seed: 'DOCK-REPAIR' });
const starter = { ...createStarterProgression(), credits: 1000 };
const result = payForHullRepair(starter, 70, 100, world.ports[0]);
assert.equal(result.repaired, true);
assert.equal(result.hull, 100);
assert.ok(result.progression.credits < starter.credits);
});

View File

@ -7,6 +7,7 @@ import {
createStarterProgression, createStarterProgression,
deriveShipStats, deriveShipStats,
describeInventoryEntry, describeInventoryEntry,
installInventoryModule,
installModule, installModule,
marketForPort, marketForPort,
salvageForPirate, salvageForPirate,
@ -130,3 +131,32 @@ test('rudder tier improves handling', () => {
assert.equal(result.credits, 250); assert.equal(result.credits, 250);
assert.equal(stats.turnRate, 88); assert.equal(stats.turnRate, 88);
}); });
test('salvaged module condition scales installed effectiveness', () => {
const starter = createStarterProgression();
const salvaged = {
...starter,
inventory: [{ moduleId: 'reinforced-rudder', condition: 0.5, source: 'salvage' }],
};
const installed = installInventoryModule(salvaged, 0);
const stats = deriveShipStats(installed.loadout, installed.installedConditions);
assert.equal(installed.installedCondition, 0.5);
assert.ok(stats.turnRate > 76);
assert.ok(stats.turnRate < 88);
});
test('installing a selected salvage entry removes that exact inventory item', () => {
const starter = createStarterProgression();
const progression = {
...starter,
inventory: [
{ moduleId: 'reinforced-rudder', condition: 0.4, source: 'salvage' },
{ moduleId: 'tuned-propellers', condition: 0.9, source: 'salvage' },
],
};
const installed = installInventoryModule(progression, 1);
assert.equal(installed.loadout.engine, 'tuned-propellers');
assert.equal(installed.inventory.length, 1);
assert.equal(installed.inventory[0].moduleId, 'reinforced-rudder');
});

View File

@ -285,6 +285,9 @@ test('mobile controls enter a dock hub before shipyard, trade, or missions', asy
assert.match(dockUi, /panel\.setAttribute\('aria-hidden'/); assert.match(dockUi, /panel\.setAttribute\('aria-hidden'/);
assert.match(dockUi, /dispatchAction\('open-shipyard'\)/); assert.match(dockUi, /dispatchAction\('open-shipyard'\)/);
assert.match(hud, /buyButton\.textContent = badges\.length/); assert.match(hud, /buyButton\.textContent = badges\.length/);
assert.match(hud, /steam-objective/);
assert.match(hud, /objective-arrow/);
assert.match(hud, /mission\.relativeBearing/);
}); });
test('shipyard overlay uses supplied component art and explicit module purchases', async () => { test('shipyard overlay uses supplied component art and explicit module purchases', async () => {
@ -305,7 +308,14 @@ test('shipyard overlay uses supplied component art and explicit module purchases
assert.match(shipyard, /data-upgrade-slot/); assert.match(shipyard, /data-upgrade-slot/);
assert.match(shipyard, /CURRENT:/); assert.match(shipyard, /CURRENT:/);
assert.match(shipyard, /NEXT:/); assert.match(shipyard, /NEXT:/);
assert.match(shipyard, /dispatchAction\('scrap-salvage'\)/); assert.match(shipyard, /dispatchAction\('scrap-salvage', \{ inventoryIndex: selectedSalvageIndex \}\)/);
assert.match(shipyard, /selectedSalvageIndex/);
assert.match(shipyard, /inventoryIndex: selectedSalvageIndex/);
assert.match(shipyard, /salvageItems/);
assert.doesNotMatch(
shipyard,
/airship:hud-state'[\s\S]{0,180}if \(!overlay\.hidden\) render\(event\.detail\)/,
);
assert.match(shipyard, /Pirate wrecks repair hull immediately on pickup/); assert.match(shipyard, /Pirate wrecks repair hull immediately on pickup/);
assert.doesNotMatch(shipyard, /data-shipyard="active"/); assert.doesNotMatch(shipyard, /data-shipyard="active"/);
assert.match(shipyard, /min-height: calc\(100dvh - 8px\)/); assert.match(shipyard, /min-height: calc\(100dvh - 8px\)/);
@ -327,12 +337,16 @@ test('dock trade and missions are driven by generated port economy state', async
read('src/dock-ui.js'), read('src/dock-ui.js'),
]); ]);
assert.match(game, /marketAtPort\(this\.world, port\)/); assert.match(game, /marketAtPort\(this\.world, port, this\.progression\.economy\)/);
assert.match(game, /missionOffersAtPort\(this\.world, port, this\.defeated\)/); assert.match(game, /missionOffersAtPort\([\s\S]*?this\.world,[\s\S]*?port,[\s\S]*?this\.defeated,[\s\S]*?this\.progression\.completedMissions/);
assert.match(game, /buyTradeGood\(/); assert.match(game, /buyTradeGood\(/);
assert.match(game, /sellTradeGood\(/); assert.match(game, /sellTradeGood\(/);
assert.match(game, /acceptMission\(/); assert.match(game, /acceptMission\(/);
assert.match(game, /completeMission\(/); assert.match(game, /completeMission\(/);
assert.match(game, /recordRouteTradeEvent\(/);
assert.match(game, /bestTradeDestination\(/);
assert.match(game, /payForHullRepair\(/);
assert.match(game, /objective = \{/);
assert.match(game, /dock: \{/); assert.match(game, /dock: \{/);
assert.match(economy, /connectedRoutes\(world, port\.id\)/); assert.match(economy, /connectedRoutes\(world, port\.id\)/);
assert.match(economy, /port\.exports/); assert.match(economy, /port\.exports/);
@ -340,6 +354,9 @@ test('dock trade and missions are driven by generated port economy state', async
assert.match(dockUi, /sell-trade-good/); assert.match(dockUi, /sell-trade-good/);
assert.match(dockUi, /accept-mission/); assert.match(dockUi, /accept-mission/);
assert.match(dockUi, /complete-mission/); assert.match(dockUi, /complete-mission/);
assert.match(dockUi, /repair-hull/);
assert.match(dockUi, /BEST KNOWN:/);
assert.match(dockUi, /data-dock="wanted"/);
assert.match(dockUi, /airship:dock-state/); assert.match(dockUi, /airship:dock-state/);
assert.match(dockUi, /latestState = event\.detail/); assert.match(dockUi, /latestState = event\.detail/);
assert.doesNotMatch( assert.doesNotMatch(
@ -385,9 +402,12 @@ test('procedural world and ship progression are wired into live gameplay', async
assert.match(source, /MODULE STORED:[\s\S]*?FIT OR SCRAP AT SHIPYARD/); assert.match(source, /MODULE STORED:[\s\S]*?FIT OR SCRAP AT SHIPYARD/);
assert.match(source, /upgradePathForSlot\(/); assert.match(source, /upgradePathForSlot\(/);
assert.match(source, /describeInventoryEntry\(/); assert.match(source, /describeInventoryEntry\(/);
assert.match(source, /installInventoryModule\(/);
assert.match(source, /salvageItems/);
assert.match(source, /installedConditions/);
assert.match(source, /bestAffordableMarketUpgrade\(/); assert.match(source, /bestAffordableMarketUpgrade\(/);
assert.match(source, /buyAndInstallModule\(/); assert.match(source, /buyAndInstallModule\(/);
assert.match(source, /installModule\(this\.progression, moduleId\)/); assert.match(source, /installInventoryModule\(this\.progression, inventoryIndex\)/);
assert.match(source, /this\.playerBroadsideReloadMs = this\.playerStats\.reloadMs/); assert.match(source, /this\.playerBroadsideReloadMs = this\.playerStats\.reloadMs/);
assert.match(source, /stats\.windExposure/); assert.match(source, /stats\.windExposure/);
}); });