/** * Stock — the origin world's time-gated daily batch. * * MODEL (model v2 — the one stateful piece of the economy, see * docs/ECONOMY.md §5 + data/economy.json → stock): every ORIGIN system's * signature good comes to market as a DAILY BATCH of `stock.dailyBatch` * units. A DAY is `stock.dayMs` of wall-clock time past `stock.epoch` — * the batch regenerates while the player is away, so the local buy→sell * loop (buy at the origin world, sell to the same-system station) is a * BOUNDED pocket change, never infinite money — while long-haul SELLs stay * unlimited (the market is a sink; the buy side is the only limit). * * The SELL side is NEVER stock-limited: a shop always buys back at its * fixed price. The state is the player's REMAINING batch per origin * system — a tiny save-safe map: * * economyStock = { [systemId]: { day: , left: } } * * (absent key = a full batch for today; a stale `day` = today's full * batch — the batch is per-DAY, so yesterday's remainder is worthless). * * Pure, framework-free, save-clone-safe (plain objects, integers). The * scene owns the map (GameScene.economyStock — save/restore in * js/save/SaveData.js); ShopView reads remaining() to render the row * ("N left" / "SOLD OUT") and to cap the stepper; confirm() decrements * via spend(). The day clock uses Date.now() (wall time — the batch is a * real-day game, not a session one); tests pass an explicit `now`. */ import { config } from '../config/Config.js'; const stockCfg = () => (config.section('economy.stock', {}) ?? {}); /** Units per origin per day (data/economy.json → stock.dailyBatch). */ export function dailyBatch() { return Math.max(1, Math.floor(Number(stockCfg().dailyBatch) || 6)); } /** Milliseconds per DAY (the batch's clock period). */ export function dayMs() { return Math.max(1000, Math.floor(Number(stockCfg().dayMs) || 3600000)); } /** The batch epoch (ms) — the day counter starts here (0 = the Unix epoch). */ export function epochMs() { const e = stockCfg().epoch; const t = typeof e === 'string' ? Date.parse(e) : (Number.isFinite(e) ? Number(e) : NaN); return Number.isFinite(t) ? t : 0; } /** * The current DAY (integer) for a wall-clock instant: floor((now − epoch) * / dayMs). The batch's identity — a purchase belongs to the day it was * made, and the batch regenerates when the day rolls over (wall clock, * so an away player finds a fresh batch on return). */ export function economyDay(now = Date.now()) { const t = Number.isFinite(now) ? Number(now) : Date.now(); return Math.floor(Math.max(0, t - epochMs()) / dayMs()); } /** * Remaining batch for an origin system on a given day. * map — the player's stock map { [systemId]: { day, left } } (save). * systemId — the ORIGIN system (the batch is per origin, per day). * day — the day to check (default: the live economyDay()). * @returns {number} 0..dailyBatch() — a full batch when the system has no * entry for that day (fresh) or its entry is from another day (stale). */ export function batchRemaining(map, systemId, day = economyDay()) { const rec = map && typeof map === 'object' ? map[systemId] : null; if (rec && Number(rec.day) === Number(day) && Number.isFinite(rec.left)) { return Math.max(0, Math.min(dailyBatch(), Math.floor(Number(rec.left)))); } return dailyBatch(); } /** * The stock map AFTER a purchase (pure — returns a new map; the scene * swaps the reference in, the save picks it up). A dry day is recorded as * `{ day, left: 0 }` (NOT dropped — an absent entry reads as a FULL batch * for the day, so a dry system must keep its zero entry). qty over the * remaining batch is clamped (the UI + tradePreview both enforce the cap; * this is the last line of defense). Stale-day entries for OTHER systems * are pruned (worthless after the day rolls). */ export function spend(map, systemId, qty, day = economyDay()) { const left = batchRemaining(map, systemId, day) - Math.max(0, Math.floor(Number(qty) || 0)); const out = {}; if (map && typeof map === 'object') { for (const [k, v] of Object.entries(map)) { if (k === systemId) continue; if (v && typeof v === 'object' && Number(v.day) === Number(day) && Number.isFinite(v.left)) { out[k] = { day: Number(v.day), left: Math.max(0, Math.floor(Number(v.left))) }; } } } out[systemId] = { day: Number(day), left: Math.max(0, Math.min(dailyBatch(), left)) }; return out; }