orbit/js/economy/Stock.js

100 lines
4.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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: <int>, left: <int> } }
*
* (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;
}