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>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'));
|
||||||
|
|
||||||
|
|||||||
156
src/game-v4.js
156
src/game-v4.js
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -23,7 +23,11 @@ const resourceToGood = (resource) => resource === 'food'
|
|||||||
? 'ore'
|
? 'ore'
|
||||||
: resource === 'fuel'
|
: resource === 'fuel'
|
||||||
? 'fuel'
|
? 'fuel'
|
||||||
: resource;
|
: resource === 'timber'
|
||||||
|
? 'machinery'
|
||||||
|
: resource;
|
||||||
|
|
||||||
|
const shortageKey = (portId, goodId) => `${portId}:${goodId}`;
|
||||||
|
|
||||||
export const tradeCargoUnits = (progression = {}) => {
|
export const tradeCargoUnits = (progression = {}) => {
|
||||||
const cargo = progression.tradeCargo || {};
|
const cargo = progression.tradeCargo || {};
|
||||||
@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@ -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');
|
||||||
|
});
|
||||||
|
|||||||
@ -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/);
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user