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