Connect mission salvage and trade progression systems

This commit is contained in:
Admin 2026-09-21 01:09:46 +00:00
parent 846af246d9
commit acb5126389
6 changed files with 455 additions and 36 deletions

View File

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

View File

@ -23,7 +23,11 @@ const resourceToGood = (resource) => resource === 'food'
? 'ore'
: resource === 'fuel'
? 'fuel'
: resource;
: resource === 'timber'
? 'machinery'
: resource;
const shortageKey = (portId, goodId) => `${portId}:${goodId}`;
export const tradeCargoUnits = (progression = {}) => {
const cargo = progression.tradeCargo || {};
@ -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,
};
};

View File

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

View File

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

View File

@ -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');
});

View File

@ -333,6 +333,10 @@ test('dock trade and missions are driven by generated port economy state', async
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/);
@ -385,6 +389,9 @@ 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\)/);