Sync connected progression economy and mission UX
This commit is contained in:
parent
2f28570334
commit
b19a3c01f8
@ -24,6 +24,7 @@ overlay.innerHTML = `
|
||||
<span>CARGO <strong data-dock="cargo">0/0</strong></span>
|
||||
<span>SALVAGE <strong data-dock="salvage">0</strong></span>
|
||||
<span>MISSION <strong data-dock="mission">NONE</strong></span>
|
||||
<span>WANTED <strong data-dock="wanted">0</strong></span>
|
||||
</div>
|
||||
|
||||
<nav class="dock-nav" aria-label="Dock services">
|
||||
@ -46,6 +47,13 @@ overlay.innerHTML = `
|
||||
<strong>SHIPYARD</strong>
|
||||
<span>Upgrade the Sparrow and manage recovered salvage.</span>
|
||||
</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 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 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-section-head { display:flex; align-items:center; justify-content:space-between; gap:10px; }
|
||||
.dock-section-head > div { display:grid; gap:2px; }
|
||||
@ -309,7 +343,13 @@ const renderMarket = (state) => {
|
||||
strong.textContent = item.name;
|
||||
const tag = document.createElement('small');
|
||||
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);
|
||||
const buy = document.createElement('span');
|
||||
buy.className = 'dock-price';
|
||||
@ -317,13 +357,20 @@ const renderMarket = (state) => {
|
||||
const sell = document.createElement('span');
|
||||
sell.className = 'dock-price';
|
||||
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');
|
||||
actions.className = 'dock-card-actions';
|
||||
actions.append(
|
||||
actionButton('BUY 1', 'buy-trade-good', { goodId: item.goodId }, !item.canBuy),
|
||||
actionButton('SELL 1', 'sell-trade-good', { goodId: item.goodId }, !item.canSell),
|
||||
);
|
||||
card.append(name, buy, sell, actions);
|
||||
card.append(name, buy, sell, hint, actions);
|
||||
fields['market-list'].append(card);
|
||||
}
|
||||
};
|
||||
@ -338,6 +385,19 @@ const render = (state) => {
|
||||
fields.cargo.textContent = `${dock.cargoUsed || 0}/${dock.cargoCapacity || 0}`;
|
||||
fields.salvage.textContent = state.progression?.inventoryCount || 0;
|
||||
fields.mission.textContent = dock.activeMission ? dock.activeMission.type.toUpperCase() : 'NONE';
|
||||
fields.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, ' ');
|
||||
renderMission(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"]')) {
|
||||
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-backdrop').addEventListener('click', () => dispatchAction('close-dock'));
|
||||
|
||||
|
||||
156
src/game-v4.js
156
src/game-v4.js
@ -36,6 +36,7 @@ import {
|
||||
createStarterProgression,
|
||||
deriveShipStats,
|
||||
describeInventoryEntry,
|
||||
installInventoryModule,
|
||||
installModule,
|
||||
marketForPort,
|
||||
salvageForPirate,
|
||||
@ -45,11 +46,15 @@ import {
|
||||
import { MODULE_CATALOG } from './progression/modules.js';
|
||||
import {
|
||||
acceptMission,
|
||||
bestTradeDestination,
|
||||
buyTradeGood,
|
||||
completeMission,
|
||||
marketAtPort,
|
||||
missionOffersAtPort,
|
||||
missionStatus,
|
||||
payForHullRepair,
|
||||
recordRouteTradeEvent,
|
||||
repairQuote,
|
||||
sellTradeGood,
|
||||
tradeCargoUnits,
|
||||
} from './progression/dock-economy.js';
|
||||
@ -114,7 +119,7 @@ class GameScene extends Phaser.Scene {
|
||||
height: WORLD_SIZE,
|
||||
});
|
||||
this.progression = createStarterProgression();
|
||||
this.playerStats = deriveShipStats(this.progression.loadout);
|
||||
this.playerStats = deriveShipStats(this.progression.loadout, this.progression.installedConditions);
|
||||
this.pendingMobileActions = new Set();
|
||||
this.pendingActionDetails = new Map();
|
||||
this.pendingModulePurchaseId = null;
|
||||
@ -545,7 +550,7 @@ class GameScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
if (this.playerStats.maxHull > previousMaxHull) {
|
||||
@ -581,6 +586,7 @@ class GameScene extends Phaser.Scene {
|
||||
const sellTradeAction = takeAction('sell-trade-good');
|
||||
const acceptMissionAction = takeAction('accept-mission');
|
||||
const completeMissionAction = takeAction('complete-mission');
|
||||
const repairHullAction = takeAction('repair-hull');
|
||||
|
||||
const keyboardDock = Phaser.Input.Keyboard.JustDown(this.keys.buyUpgrade);
|
||||
const keyboardEscape = Phaser.Input.Keyboard.JustDown(this.keys.closeShipyard);
|
||||
@ -661,7 +667,8 @@ class GameScene extends Phaser.Scene {
|
||||
if (!this.shipyardOpen || !docked) {
|
||||
this.status.setText('FIT RECOVERED MODULES AT THE SHIPYARD');
|
||||
} 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'
|
||||
? salvageEntry
|
||||
: salvageEntry?.moduleId;
|
||||
@ -669,10 +676,11 @@ class GameScene extends Phaser.Scene {
|
||||
this.status.setText('SALVAGE HOLD IS EMPTY');
|
||||
} else {
|
||||
const previousMaxHull = this.playerStats.maxHull;
|
||||
this.progression = installModule(this.progression, moduleId);
|
||||
this.progression = installInventoryModule(this.progression, inventoryIndex);
|
||||
this.applyProgressionStats(previousMaxHull);
|
||||
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', {
|
||||
detail: this.buildHudState(),
|
||||
@ -685,7 +693,8 @@ class GameScene extends Phaser.Scene {
|
||||
this.status.setText('SCRAP RECOVERED MODULES AT THE SHIPYARD');
|
||||
} else {
|
||||
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);
|
||||
this.status.setText(
|
||||
this.progression.scrapped
|
||||
@ -699,7 +708,7 @@ class GameScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
if (this.dockOpen && docked && !this.shipyardOpen) {
|
||||
const market = marketAtPort(this.world, port);
|
||||
const market = marketAtPort(this.world, port, this.progression.economy);
|
||||
|
||||
if (buyTradeAction) {
|
||||
const item = market.find(({ goodId }) => goodId === buyTradeAction.goodId);
|
||||
@ -724,7 +733,12 @@ class GameScene extends Phaser.Scene {
|
||||
}
|
||||
|
||||
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);
|
||||
this.progression = acceptMission(
|
||||
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', {
|
||||
detail: this.buildHudState(),
|
||||
}));
|
||||
@ -1066,6 +1098,14 @@ class GameScene extends Phaser.Scene {
|
||||
const range = Math.hypot(target.x - merchant.x, target.y - merchant.y);
|
||||
|
||||
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('deliveries', (merchant.getData('deliveries') || 0) + 1);
|
||||
this.tradeDeliveries += 1;
|
||||
@ -1154,10 +1194,29 @@ class GameScene extends Phaser.Scene {
|
||||
const cargoValue = merchant.getData('cargoValue') || 50;
|
||||
const x = merchant.x;
|
||||
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();
|
||||
this.spawnExplosion(x, y, 1.05);
|
||||
this.tradeLosses += 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 = {
|
||||
moduleId: null,
|
||||
@ -1380,7 +1439,7 @@ class GameScene extends Phaser.Scene {
|
||||
|
||||
updatePlayer(dt, deltaMs) {
|
||||
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 helmX = Math.sign(rawHelmX) * Math.pow(Math.abs(rawHelmX), HELM_STEER_CURVE);
|
||||
@ -1811,23 +1870,70 @@ class GameScene extends Phaser.Scene {
|
||||
const marketUpgrade = docked
|
||||
? bestAffordableMarketUpgrade(this.progression, portState.port)
|
||||
: null;
|
||||
const salvageEntry = this.progression?.inventory?.[0] || null;
|
||||
const salvageItem = describeInventoryEntry(salvageEntry);
|
||||
const salvageItems = (this.progression?.inventory || [])
|
||||
.map((entry, index) => {
|
||||
const details = describeInventoryEntry(entry);
|
||||
return details ? { ...details, inventoryIndex: index } : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
const salvageItem = salvageItems[0] || null;
|
||||
const upgradePaths = Object.fromEntries(
|
||||
['engine', 'hull', 'cargo', 'rudder'].map((slot) => [
|
||||
slot,
|
||||
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 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, {
|
||||
portId: docked ? portState.port?.id : null,
|
||||
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 {
|
||||
gameState: this.gameState,
|
||||
@ -1851,6 +1957,7 @@ class GameScene extends Phaser.Scene {
|
||||
port: reloadReady('port'),
|
||||
starboard: reloadReady('starboard'),
|
||||
},
|
||||
objective,
|
||||
environment: {
|
||||
windSpeed: this.environmentWind?.strength || 0,
|
||||
windHeading: this.environmentWind?.heading || 0,
|
||||
@ -1873,9 +1980,16 @@ class GameScene extends Phaser.Scene {
|
||||
cargoCapacity: this.playerStats?.cargoCapacity || 0,
|
||||
market: dockMarket.map((item) => {
|
||||
const held = this.progression.tradeCargo?.[item.goodId] || 0;
|
||||
const opportunity = bestTradeDestination(
|
||||
this.world,
|
||||
portState.port,
|
||||
item.goodId,
|
||||
this.progression.economy,
|
||||
);
|
||||
return {
|
||||
...item,
|
||||
held,
|
||||
opportunity,
|
||||
canBuy: (this.progression.credits || 0) >= item.buyPrice
|
||||
&& cargoUsed < (this.playerStats?.cargoCapacity || 0),
|
||||
canSell: held > 0,
|
||||
@ -1883,6 +1997,9 @@ class GameScene extends Phaser.Scene {
|
||||
}),
|
||||
missionOffers,
|
||||
activeMission,
|
||||
repair,
|
||||
wanted: this.progression.economy?.wanted || 0,
|
||||
reputation: this.progression.economy?.reputation || 0,
|
||||
},
|
||||
progression: {
|
||||
credits: this.progression?.credits || 0,
|
||||
@ -1895,6 +2012,7 @@ class GameScene extends Phaser.Scene {
|
||||
shipyardOpen: this.shipyardOpen,
|
||||
shipStats: { ...this.playerStats },
|
||||
loadout: { ...this.progression.loadout },
|
||||
installedConditions: { ...(this.progression.installedConditions || {}) },
|
||||
marketStock: marketStock.map((module) => ({
|
||||
id: module.id,
|
||||
name: module.name,
|
||||
@ -1909,6 +2027,7 @@ class GameScene extends Phaser.Scene {
|
||||
name: marketUpgrade.name,
|
||||
price: marketUpgrade.price,
|
||||
} : null,
|
||||
salvageItems,
|
||||
salvageItem,
|
||||
salvageModule: salvageItem ? {
|
||||
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.headingText.setText(`${(state.heading || 0).toFixed(0).padStart(3, '0')}°`);
|
||||
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
|
||||
? `K ${state.kills}\nT${state.trade?.activeMerchants || 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}`);
|
||||
? `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}${objectiveText ? `\nMISSION ${objectiveText}` : ''}`);
|
||||
|
||||
this.playerHullLabel.setText(`SPARROW ${playerHull}/${playerMaxHull}`);
|
||||
this.enemyHullLabel.setText(target
|
||||
|
||||
@ -326,6 +326,42 @@ if (!isDesktopPointer.matches) {
|
||||
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 {
|
||||
left: 1%;
|
||||
bottom: max(8px, env(safe-area-inset-bottom));
|
||||
@ -697,6 +733,14 @@ if (!isDesktopPointer.matches) {
|
||||
status.dataset.steam = '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(
|
||||
[...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['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 || {};
|
||||
if (buyButton) {
|
||||
buyButton.hidden = !progression.docked;
|
||||
|
||||
@ -23,8 +23,12 @@ const resourceToGood = (resource) => resource === 'food'
|
||||
? 'ore'
|
||||
: resource === 'fuel'
|
||||
? 'fuel'
|
||||
: resource === 'timber'
|
||||
? 'machinery'
|
||||
: resource;
|
||||
|
||||
const shortageKey = (portId, goodId) => `${portId}:${goodId}`;
|
||||
|
||||
export const tradeCargoUnits = (progression = {}) => {
|
||||
const cargo = progression.tradeCargo || {};
|
||||
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;
|
||||
};
|
||||
|
||||
export const marketAtPort = (world, port) => {
|
||||
export const marketAtPort = (world, port, economy = {}) => {
|
||||
if (!port) return [];
|
||||
const typeMods = TYPE_MODIFIERS[port.type] || TYPE_MODIFIERS.trade;
|
||||
const exports = new Set((port.exports || []).map(resourceToGood));
|
||||
const imports = new Set((port.imports || []).map(resourceToGood));
|
||||
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) => {
|
||||
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;
|
||||
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));
|
||||
return {
|
||||
goodId: good.id,
|
||||
@ -56,10 +67,35 @@ export const marketAtPort = (world, port) => {
|
||||
sellPrice: Math.max(5, Math.round(localPrice * 0.86)),
|
||||
exported: exports.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) => {
|
||||
const qty = Math.max(1, Math.floor(quantity));
|
||||
const price = Math.max(0, marketItem?.buyPrice || 0) * qty;
|
||||
@ -111,15 +147,17 @@ const otherPortId = (route, portId) => route.fromPortId === portId
|
||||
? route.toPortId
|
||||
: route.fromPortId;
|
||||
|
||||
export const missionOffersAtPort = (world, port, defeated = 0) => {
|
||||
export const missionOffersAtPort = (world, port, defeated = 0, cycle = 0) => {
|
||||
if (!port) return [];
|
||||
const random = createSeededRandom(`${world?.seed || 'world'}:${port.id}:missions:${cycle}`);
|
||||
const routes = connectedRoutes(world, port.id)
|
||||
.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
|
||||
? world.ports.find(({ id }) => id === otherPortId(deliveryRoute, port.id))
|
||||
: null;
|
||||
const random = createSeededRandom(`${world?.seed || 'world'}:${port.id}:missions`);
|
||||
const offers = [];
|
||||
|
||||
if (deliveryRoute && targetPort) {
|
||||
@ -127,7 +165,7 @@ export const missionOffersAtPort = (world, port, defeated = 0) => {
|
||||
const distance = routeDistance(deliveryRoute);
|
||||
const reward = Math.round(120 + distance * 0.11 + deliveryRoute.value * 160 + cargoUnits * 30);
|
||||
offers.push({
|
||||
id: `delivery:${port.id}:${targetPort.id}`,
|
||||
id: `delivery:${cycle}:${port.id}:${targetPort.id}`,
|
||||
type: 'delivery',
|
||||
title: `Courier Run → ${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,
|
||||
cargoUnits,
|
||||
reward,
|
||||
boardCycle: cycle,
|
||||
});
|
||||
}
|
||||
|
||||
const killTarget = 2 + Math.floor(random() * 3);
|
||||
offers.push({
|
||||
id: `bounty:${port.id}:${killTarget}`,
|
||||
id: `bounty:${cycle}:${port.id}:${killTarget}`,
|
||||
type: 'bounty',
|
||||
title: `Pirate Bounty ×${killTarget}`,
|
||||
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,
|
||||
reward: 150 + killTarget * 90,
|
||||
baselineKills: defeated,
|
||||
boardCycle: cycle,
|
||||
});
|
||||
|
||||
return offers;
|
||||
@ -204,7 +244,96 @@ export const completeMission = (progression, context = {}) => {
|
||||
...progression,
|
||||
credits: (progression.credits || 0) + Math.max(0, status.reward || 0),
|
||||
activeMission: null,
|
||||
completedMissions: (progression.completedMissions || 0) + 1,
|
||||
missionCompleted: true,
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
@ -17,20 +17,29 @@ export const createStarterProgression = () => ({
|
||||
credits: 650,
|
||||
scrap: 0,
|
||||
loadout: { ...STARTER_LOADOUT },
|
||||
installedConditions: {},
|
||||
inventory: [],
|
||||
tradeCargo: {},
|
||||
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 };
|
||||
|
||||
for (const moduleId of Object.values(loadout)) {
|
||||
for (const [slot, moduleId] of Object.entries(loadout)) {
|
||||
if (!moduleId) continue;
|
||||
const module = MODULE_CATALOG[moduleId];
|
||||
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 || {})) {
|
||||
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];
|
||||
if (!module) return { ...progression, installed: false, reason: 'unknown-module' };
|
||||
if (progression.loadout?.[module.slot] === moduleId) {
|
||||
@ -115,22 +124,71 @@ export const installModule = (progression, moduleId) => {
|
||||
const installedEntry = inventoryIndex >= 0 ? inventory[inventoryIndex] : null;
|
||||
if (inventoryIndex >= 0) inventory.splice(inventoryIndex, 1);
|
||||
|
||||
const loadout = { ...(progression.loadout || STARTER_LOADOUT) };
|
||||
const replaced = loadout[module.slot] || null;
|
||||
loadout[module.slot] = moduleId;
|
||||
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] = installedEntry
|
||||
? inventoryCondition(installedEntry)
|
||||
: clamp(Number(condition) || 1, 0.05, 1);
|
||||
|
||||
if (replaced && MODULE_CATALOG[replaced]?.price > 0) {
|
||||
inventory.push({ moduleId: replaced, condition: 1, source: 'removed' });
|
||||
inventory.push({
|
||||
moduleId: replaced,
|
||||
condition: replacedCondition,
|
||||
source: 'removed',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...progression,
|
||||
loadout,
|
||||
loadout: loadoutState,
|
||||
installedConditions,
|
||||
inventory,
|
||||
installed: true,
|
||||
replaced,
|
||||
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 installedConditions = {
|
||||
...(progression.installedConditions || {}),
|
||||
[module.slot]: 1,
|
||||
};
|
||||
return {
|
||||
...progression,
|
||||
credits: progression.credits - module.price,
|
||||
loadout,
|
||||
installedConditions,
|
||||
purchased: true,
|
||||
installed: true,
|
||||
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 || [])];
|
||||
let moduleCollected = false;
|
||||
|
||||
@ -244,7 +311,7 @@ export const scrapInventoryModule = (progression, index = 0) => {
|
||||
};
|
||||
|
||||
export const bestAffordableMarketUpgrade = (progression, port) => {
|
||||
const stats = deriveShipStats(progression.loadout);
|
||||
const stats = deriveShipStats(progression.loadout, progression.installedConditions);
|
||||
const candidates = marketForPort(port)
|
||||
.filter((module) => {
|
||||
if (!Number.isInteger(module.tier)) {
|
||||
@ -256,7 +323,10 @@ export const bestAffordableMarketUpgrade = (progression, port) => {
|
||||
})
|
||||
.filter((module) => module.price <= (progression.credits || 0))
|
||||
.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
|
||||
+ (next.maxSpeed - stats.maxSpeed)
|
||||
+ (next.acceleration - stats.acceleration) * 0.45
|
||||
|
||||
@ -79,12 +79,14 @@ overlay.innerHTML = `
|
||||
<footer class="shipyard-footer">
|
||||
<div class="shipyard-salvage-panel">
|
||||
<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-meta">Pirate wrecks repair hull immediately.</span>
|
||||
<span data-shipyard="salvage-effect">Recovered modules are stored here to fit or scrap.</span>
|
||||
</div>
|
||||
<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 shipyard-scrap" type="button" data-shipyard="scrap">SCRAP MODULE</button>
|
||||
</div>
|
||||
@ -477,9 +479,12 @@ const fields = Object.fromEntries(
|
||||
const closeButton = overlay.querySelector('.shipyard-close');
|
||||
const fitButton = overlay.querySelector('[data-shipyard="fit"]');
|
||||
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')];
|
||||
|
||||
let latestState = null;
|
||||
let selectedSalvageIndex = 0;
|
||||
|
||||
const render = (state) => {
|
||||
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) {
|
||||
const effectiveness = Math.round(55 + salvage.condition * 45);
|
||||
fields['salvage-name'].textContent = salvage.name;
|
||||
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 =
|
||||
`${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.textContent = 'FIT MODULE';
|
||||
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', () => {
|
||||
if (fitButton.disabled) return;
|
||||
dispatchAction('fit-salvage');
|
||||
dispatchAction('fit-salvage', { inventoryIndex: selectedSalvageIndex });
|
||||
});
|
||||
|
||||
scrapButton.addEventListener('click', () => {
|
||||
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-state', (event) => render(event.detail));
|
||||
window.addEventListener('airship:shipyard-close', hide);
|
||||
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;
|
||||
if (!overlay.hidden) render(event.detail);
|
||||
});
|
||||
window.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape' && !overlay.hidden) {
|
||||
|
||||
@ -5,11 +5,14 @@ import { generateWorld } from '../src/world/world-generator.js';
|
||||
import { createStarterProgression, deriveShipStats } from '../src/progression/ship-loadout.js';
|
||||
import {
|
||||
acceptMission,
|
||||
bestTradeDestination,
|
||||
buyTradeGood,
|
||||
completeMission,
|
||||
marketAtPort,
|
||||
missionOffersAtPort,
|
||||
missionStatus,
|
||||
payForHullRepair,
|
||||
recordRouteTradeEvent,
|
||||
sellTradeGood,
|
||||
tradeCargoUnits,
|
||||
} from '../src/progression/dock-economy.js';
|
||||
@ -85,3 +88,59 @@ test('bounty missions advance from actual pirate kill count', () => {
|
||||
});
|
||||
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);
|
||||
});
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
createStarterProgression,
|
||||
deriveShipStats,
|
||||
describeInventoryEntry,
|
||||
installInventoryModule,
|
||||
installModule,
|
||||
marketForPort,
|
||||
salvageForPirate,
|
||||
@ -130,3 +131,32 @@ test('rudder tier improves handling', () => {
|
||||
assert.equal(result.credits, 250);
|
||||
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');
|
||||
});
|
||||
|
||||
@ -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, /dispatchAction\('open-shipyard'\)/);
|
||||
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 () => {
|
||||
@ -305,7 +308,14 @@ test('shipyard overlay uses supplied component art and explicit module purchases
|
||||
assert.match(shipyard, /data-upgrade-slot/);
|
||||
assert.match(shipyard, /CURRENT:/);
|
||||
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.doesNotMatch(shipyard, /data-shipyard="active"/);
|
||||
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'),
|
||||
]);
|
||||
|
||||
assert.match(game, /marketAtPort\(this\.world, port\)/);
|
||||
assert.match(game, /missionOffersAtPort\(this\.world, port, this\.defeated\)/);
|
||||
assert.match(game, /marketAtPort\(this\.world, port, this\.progression\.economy\)/);
|
||||
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, /sellTradeGood\(/);
|
||||
assert.match(game, /acceptMission\(/);
|
||||
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(economy, /connectedRoutes\(world, port\.id\)/);
|
||||
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, /accept-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, /latestState = event\.detail/);
|
||||
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, /upgradePathForSlot\(/);
|
||||
assert.match(source, /describeInventoryEntry\(/);
|
||||
assert.match(source, /installInventoryModule\(/);
|
||||
assert.match(source, /salvageItems/);
|
||||
assert.match(source, /installedConditions/);
|
||||
assert.match(source, /bestAffordableMarketUpgrade\(/);
|
||||
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, /stats\.windExposure/);
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user