diff --git a/src/progression/dock-economy.js b/src/progression/dock-economy.js new file mode 100644 index 0000000..4e22548 --- /dev/null +++ b/src/progression/dock-economy.js @@ -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, + }; +};