Add dock economy and mission systems
This commit is contained in:
parent
bb302b0ac7
commit
c8ced60086
210
src/progression/dock-economy.js
Normal file
210
src/progression/dock-economy.js
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
import { createSeededRandom } from '../world/seeded-random.js';
|
||||||
|
|
||||||
|
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
|
||||||
|
|
||||||
|
export const TRADE_GOODS = {
|
||||||
|
provisions: { id: 'provisions', name: 'Provisions', basePrice: 34 },
|
||||||
|
ore: { id: 'ore', name: 'Ore', basePrice: 52 },
|
||||||
|
fuel: { id: 'fuel', name: 'Aether Fuel', basePrice: 46 },
|
||||||
|
machinery: { id: 'machinery', name: 'Machinery', basePrice: 82 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const TYPE_MODIFIERS = {
|
||||||
|
capital: { provisions: 0.96, ore: 1.02, fuel: 1.0, machinery: 0.94 },
|
||||||
|
industrial: { provisions: 1.08, ore: 0.72, fuel: 0.96, machinery: 0.8 },
|
||||||
|
fuel: { provisions: 1.08, ore: 1.04, fuel: 0.7, machinery: 1.06 },
|
||||||
|
military: { provisions: 1.12, ore: 1.1, fuel: 1.04, machinery: 1.14 },
|
||||||
|
trade: { provisions: 0.94, ore: 1.0, fuel: 1.0, machinery: 0.96 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const resourceToGood = (resource) => resource === 'food'
|
||||||
|
? 'provisions'
|
||||||
|
: resource === 'ore'
|
||||||
|
? 'ore'
|
||||||
|
: resource === 'fuel'
|
||||||
|
? 'fuel'
|
||||||
|
: resource;
|
||||||
|
|
||||||
|
export const tradeCargoUnits = (progression = {}) => {
|
||||||
|
const cargo = progression.tradeCargo || {};
|
||||||
|
const goods = Object.values(cargo).reduce((sum, qty) => sum + Math.max(0, Number(qty) || 0), 0);
|
||||||
|
const missionUnits = progression.activeMission?.type === 'delivery'
|
||||||
|
? Math.max(0, progression.activeMission.cargoUnits || 0)
|
||||||
|
: 0;
|
||||||
|
return goods + missionUnits;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const marketAtPort = (world, port) => {
|
||||||
|
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));
|
||||||
|
|
||||||
|
return Object.values(TRADE_GOODS).map((good) => {
|
||||||
|
const random = createSeededRandom(`${world?.seed || 'world'}:${port.id}:${good.id}:market`);
|
||||||
|
const jitter = 0.93 + random() * 0.14;
|
||||||
|
let modifier = typeMods[good.id] || 1;
|
||||||
|
if (exports.has(good.id)) modifier *= 0.76;
|
||||||
|
if (imports.has(good.id)) modifier *= 1.2;
|
||||||
|
modifier *= 0.96 + (1 - (port.wealth || 0.5)) * 0.08;
|
||||||
|
|
||||||
|
const localPrice = Math.max(8, Math.round(good.basePrice * modifier * jitter));
|
||||||
|
return {
|
||||||
|
goodId: good.id,
|
||||||
|
name: good.name,
|
||||||
|
buyPrice: localPrice,
|
||||||
|
sellPrice: Math.max(5, Math.round(localPrice * 0.86)),
|
||||||
|
exported: exports.has(good.id),
|
||||||
|
imported: imports.has(good.id),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
if (!marketItem?.goodId || price <= 0) return { ...progression, traded: false, reason: 'invalid-good' };
|
||||||
|
if ((progression.credits || 0) < price) return { ...progression, traded: false, reason: 'insufficient-credits' };
|
||||||
|
if (tradeCargoUnits(progression) + qty > (stats?.cargoCapacity || 0)) {
|
||||||
|
return { ...progression, traded: false, reason: 'cargo-full' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const tradeCargo = { ...(progression.tradeCargo || {}) };
|
||||||
|
tradeCargo[marketItem.goodId] = (tradeCargo[marketItem.goodId] || 0) + qty;
|
||||||
|
return {
|
||||||
|
...progression,
|
||||||
|
tradeCargo,
|
||||||
|
credits: progression.credits - price,
|
||||||
|
traded: true,
|
||||||
|
tradeAction: { type: 'buy', goodId: marketItem.goodId, quantity: qty, price },
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sellTradeGood = (progression, marketItem, quantity = 1) => {
|
||||||
|
const qty = Math.max(1, Math.floor(quantity));
|
||||||
|
const held = Math.max(0, progression.tradeCargo?.[marketItem?.goodId] || 0);
|
||||||
|
if (!marketItem?.goodId || held < qty) return { ...progression, traded: false, reason: 'not-held' };
|
||||||
|
|
||||||
|
const tradeCargo = { ...(progression.tradeCargo || {}) };
|
||||||
|
tradeCargo[marketItem.goodId] = held - qty;
|
||||||
|
if (tradeCargo[marketItem.goodId] <= 0) delete tradeCargo[marketItem.goodId];
|
||||||
|
const price = Math.max(0, marketItem.sellPrice || 0) * qty;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...progression,
|
||||||
|
tradeCargo,
|
||||||
|
credits: (progression.credits || 0) + price,
|
||||||
|
traded: true,
|
||||||
|
tradeAction: { type: 'sell', goodId: marketItem.goodId, quantity: qty, price },
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const routeDistance = (route) => Math.hypot(
|
||||||
|
(route?.to?.x || 0) - (route?.from?.x || 0),
|
||||||
|
(route?.to?.y || 0) - (route?.from?.y || 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
const connectedRoutes = (world, portId) => (world?.tradeRoutes || [])
|
||||||
|
.filter((route) => route.fromPortId === portId || route.toPortId === portId);
|
||||||
|
|
||||||
|
const otherPortId = (route, portId) => route.fromPortId === portId
|
||||||
|
? route.toPortId
|
||||||
|
: route.fromPortId;
|
||||||
|
|
||||||
|
export const missionOffersAtPort = (world, port, defeated = 0) => {
|
||||||
|
if (!port) return [];
|
||||||
|
const routes = connectedRoutes(world, port.id)
|
||||||
|
.sort((a, b) => b.value - a.value || routeDistance(b) - routeDistance(a));
|
||||||
|
const deliveryRoute = routes[0] || 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) {
|
||||||
|
const cargoUnits = 1 + Math.floor(random() * 2);
|
||||||
|
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}`,
|
||||||
|
type: 'delivery',
|
||||||
|
title: `Courier Run → ${targetPort.name}`,
|
||||||
|
description: `Carry ${cargoUnits} sealed cargo unit${cargoUnits === 1 ? '' : 's'} to ${targetPort.name}.`,
|
||||||
|
originPortId: port.id,
|
||||||
|
originPortName: port.name,
|
||||||
|
targetPortId: targetPort.id,
|
||||||
|
targetPortName: targetPort.name,
|
||||||
|
cargoUnits,
|
||||||
|
reward,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const killTarget = 2 + Math.floor(random() * 3);
|
||||||
|
offers.push({
|
||||||
|
id: `bounty:${port.id}:${killTarget}`,
|
||||||
|
type: 'bounty',
|
||||||
|
title: `Pirate Bounty ×${killTarget}`,
|
||||||
|
description: `Destroy ${killTarget} pirate airship${killTarget === 1 ? '' : 's'} and claim the bounty at any dock.`,
|
||||||
|
originPortId: port.id,
|
||||||
|
originPortName: port.name,
|
||||||
|
targetKills: killTarget,
|
||||||
|
reward: 150 + killTarget * 90,
|
||||||
|
baselineKills: defeated,
|
||||||
|
});
|
||||||
|
|
||||||
|
return offers;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const acceptMission = (progression, mission, stats, defeated = 0) => {
|
||||||
|
if (!mission) return { ...progression, missionAccepted: false, reason: 'invalid-mission' };
|
||||||
|
if (progression.activeMission) return { ...progression, missionAccepted: false, reason: 'mission-active' };
|
||||||
|
if (mission.type === 'delivery' && tradeCargoUnits(progression) + (mission.cargoUnits || 0) > (stats?.cargoCapacity || 0)) {
|
||||||
|
return { ...progression, missionAccepted: false, reason: 'cargo-full' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...progression,
|
||||||
|
activeMission: {
|
||||||
|
...mission,
|
||||||
|
baselineKills: mission.type === 'bounty' ? defeated : mission.baselineKills,
|
||||||
|
},
|
||||||
|
missionAccepted: true,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const missionStatus = (progression, { portId = null, defeated = 0 } = {}) => {
|
||||||
|
const mission = progression?.activeMission || null;
|
||||||
|
if (!mission) return null;
|
||||||
|
|
||||||
|
if (mission.type === 'delivery') {
|
||||||
|
const ready = portId === mission.targetPortId;
|
||||||
|
return {
|
||||||
|
...mission,
|
||||||
|
progressText: ready ? `Arrived at ${mission.targetPortName}` : `Deliver to ${mission.targetPortName}`,
|
||||||
|
ready,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const kills = Math.max(0, defeated - (mission.baselineKills || 0));
|
||||||
|
return {
|
||||||
|
...mission,
|
||||||
|
kills,
|
||||||
|
progressText: `${Math.min(kills, mission.targetKills)}/${mission.targetKills} pirates destroyed`,
|
||||||
|
ready: kills >= mission.targetKills,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const completeMission = (progression, context = {}) => {
|
||||||
|
const status = missionStatus(progression, context);
|
||||||
|
if (!status) return { ...progression, missionCompleted: false, reason: 'no-mission' };
|
||||||
|
if (!status.ready) return { ...progression, missionCompleted: false, reason: 'not-complete' };
|
||||||
|
|
||||||
|
return {
|
||||||
|
...progression,
|
||||||
|
credits: (progression.credits || 0) + Math.max(0, status.reward || 0),
|
||||||
|
activeMission: null,
|
||||||
|
missionCompleted: true,
|
||||||
|
lastCompletedMission: status,
|
||||||
|
};
|
||||||
|
};
|
||||||
Loading…
x
Reference in New Issue
Block a user