SpaceCities / docs /logistics-design.md
Claude
Remove the Refinery/Foundry/Arsenal forward drop-off capability
4ed9122 unverified
|
Raw History Blame
26 kB

Logistics overhaul β€” brainstorm & phased design

Goal (from the request): make storage finite so collection and production buildings can fill up and stall, and make workers haul goods between them, so logistics stays a live, mindful process that keeps workers relevant to the end of the game. Plus energy efficiency by Reactor proximity with placement cues.

Status: the energy half is done and merged (see below). The *finite-storage

  • haulage* half is a core-economy reshape and is designed here for a dedicated, phased implementation β€” this doc is the plan of record.

1. What's already shipped β€” energy grid efficiency

Energy is a per-tick flow, not a hauled good, so its "logistics" is a placement decision β€” no workers needed. Implemented in engine/industry.js:

  • POWER_TIERS β€” distance-to-nearest-Reactor bands: on-grid Γ—1.0 β†’ near Γ—1.3 β†’ far Γ—1.7 β†’ isolated Γ—2.3 (draw multiplier).
  • powerEfficiency(state, owner, x, y) β€” the tier for a spot.
  • powerDraw scales every consumer (factory, rig, charging Gate) by its tier, so a remote building bleeds capacity to transmission loss and throttles the whole grid; clustering around a Reactor lets the same Reactors run more of it.

Visual cues mirror the Spaceport's jump radius (render.js): a selected Reactor shows concentric efficiency zones; a build ghost for a power consumer shows the nearest Reactor's zones, a connector, and the tier it would land in ("Far Β· draw Γ—1.7"); a selected factory/rig gains a colour-keyed "Grid: … Β· draws Γ—N" line (hud.js). Pure distance math β€” deterministic, DOM-free, no new stored state.


2. Finite storage + haulage β€” the hard half

2.1 The core tension (why this is a real reshape, not a patch)

Today there is ONE global stockpile per side: player.resources (a commodity→float map). Everything reads/writes it:

System Uses player.resources for
gather.js workers bank hauls here
industry.js factories draw inputs here, bank outputs here
rig.js banks dug raws here
market.js buy/sell price + settle against it
production.js canAfford / payCost for units, buildings, upgrades
wonder.js the Gate's feed is drawn from here
ai.js every AI economic decision reads it
hud.js the resource topbar shows it

"Finite per-building storage" means goods no longer live in one global pool β€” they live in building buffers and only become spendable once hauled to a Command Center. That touches every row above. It must be phased, determinism-first, or it breaks the byte-identical replay law and the AI at once.

2.2 Scope insight β€” who builds what

The AI builds none of the industry chain (no smelter/assembler/chipfab/ machineworks/reactor/plasmarig/…/torpedoworks). So production-side storage + haulage is player-only and self-contained. The collection-side (Command Center + Refinery/Foundry/Arsenal drop-offs) IS used by the AI, so finite collection storage cascades to AI worker logic β€” the riskier half.

2.3 Forgotten aspects surfaced by the brainstorm

  1. The AI must not deadlock. If drop-offs can fill, the AI's gather loop must reroute or it starves. Simplest safe rule: the AI's Command Center keeps an effectively huge cap (tier-scaled), so the AI never softlocks while the player opts into tighter forward logistics. Alternatively, exempt the AI entirely in phase 1 and only make the player's buildings finite.
  2. What is "spendable"? Decide up front: are build/unit/upgrade costs and the market paid from (a) the global pool as today, fed by haulage into the CC, or (b) summed across all buildings? Recommendation: (a) β€” the CC pool is the treasury; buffers are in-transit inventory. Keeps canAfford/market/AI intact.
  3. Determinism. Any "nearest full/needy building" or worker task-assignment must be order-independent and tie-broken deterministically (by id/hash, never by Map iteration + wall clock). Reuse gather.js's nearestDropoff pattern + hashStr.
  4. Save/load. New store / input buffers on buildings must be coerced in persist.js cleanEntity (COM-valid keys, β‰₯0, clamped to cap) exactly like freight and the rig's digProgress.
  5. Auto vs manual haulage. Workers should auto-haul (RTS players won't micro every crate), but the player should be able to prioritise a building. Phase 1: fully automatic, nearest-worker/nearest-target, so it "just works".
  6. Idle-worker sourcing. Haulage needs a labour pool. Reuse idle workers (no gather order); if none, production simply stalls β€” which is the intended pressure, not a bug. Surface it ("output full β€” assign a hauler").
  7. Deadlock & starvation UX. A stalled factory (output full / input starved) already has a status line; extend it with the reason and a one-click "haul now".
  8. Balance / caps. Buffer sizes set the rhythm. Too big β†’ no logistics pressure; too small β†’ constant micro. Start generous (minutes of runtime per buffer) and tune. CC cap scales with a storeTier (Capital upgrade β†’ bigger treasury).
  9. Market side-effect. If output must be hauled before it's sellable, a player with full rig buffers but no haulers can't sell β€” intended, but must read clearly in the HUD, not as "the market is broken".
  10. Freighters. Interplanetary freight already exists (unit.freight); keep it orthogonal β€” freighters move goods between worlds, workers move goods within a world. Don't conflate. Superseded by Phase D below, which deliberately DOES connect the two: workers can now physically load/unload a landed freighter's hold (the ship becomes a mobile collection point within a world), and a researched freighter can join the within-world haul/service chain itself. The panel-driven load/unload (this section) and the interplanetary jump (unit.freight, moved between worlds) are untouched β€” Phase D adds a THIRD, physical path onto the same hold, it doesn't replace the first two.

2.4 Phased plan (each phase is its own merge, suite-green + determinism-verified)

Phase A β€” Output buffer + worker-haulage foundation on the Plasma Rig. βœ… DONE.

  • Added building.store (commodityβ†’qty) + def.storeCap, with pure helpers storeTotal/storeRoom/storeCapOf (entities.js).
  • updatePlasmaRig banks each dig into store (topping off the final dig to exactly storeCap, overflow spills) and stalls the moment the buffer is full β€” an unlimited source, no longer an unlimited sink.
  • New worker task haul (engine/haul.js): an idle player worker auto-assigns to the nearest own producer whose buffer is β‰₯34% full (≀2 haulers each, tie-broken by id), walks there, loads a cargo, carries it to the nearest drop-off, and banks it into the treasury 1:1 (no gather multiplier β€” the goods were already extracted). A per-tick hauler tally (countHaulers) is frozen before assignment for determinism.
  • render.js: a gold/red output-buffer gauge under a producer's hull.
  • hud.js: rig panel shows "Output NN/CAP (N%)" + a "buffer full β†’ needs a hauler" stall reason.
  • persist.js: coerce store (COM-valid, β‰₯0, clamped to cap); strip the transient haulers tally on serialize (like a unit's _gi).
  • Player-only & AI-safe: the auto-haul call is gated to owner === "player", and no skirmish building has a buffer, so the AI and the byte-identical skirmish replay are untouched. Tests: test/haul.test.js (+ rewritten test/rig.test.js).
  • Why the Rig first: it's a leaf producer of raw goods β€” its output doesn't feed another factory, so gating it doesn't half-break the two-hop chain. This proves the whole haulage subsystem (store, task, auto-assign, render, persist, determinism) on the safest surface before it touches the coupled factory chain.

Phase B β€” Factory output + INPUT buffers = a real supply network. βœ… DONE.

  • Factories now get a default output buffer AND a local input larder (both cap 80, via storeCapOf/inputCapOf β€” any building with a recipe). updateProduction draws inputs from the local larder, banks output to the local store, and stalls when either the larder is empty or the output buffer is full β€” so a factory only runs while it's been supplied and has room.
  • Haulage gained a supply leg (engine/haul.js): an idle player worker picks the nearest own factory that's low on an input the treasury can provide, carries a cargo from the treasury (the CC/warehouse) into the factory's larder. Output is drained by the Phase A haul path, which now covers factories automatically. Idle workers try haul first (clear backlogs / refill the treasury) then supply (feed the starving).
  • The chain is now a real network routed through the treasury: raws β†’ CC β†’ supply β†’ smelter β†’ metals β†’ haul β†’ CC β†’ supply β†’ assembler β†’ alloys β†’ haul β†’ CC. Verified end-to-end in the live sim + browser (alloys reach the treasury purely via worker logistics).
  • persist.js coerces the input buffer and strips the transient suppliers tally; hud.js factory panel shows the larder + output buffer and a "needs it carried in" starve reason; the map store bar (Phase A) now covers factories too.
  • Rewrote the industry.js + strategic.js chain tests to the buffered model; added supply tests to test/haul.test.js. Player-only & AI-safe as in Phase A.

Balance note (both phases): default caps are generous (80 / rig 120). Tune later β€” smaller = more logistics pressure, larger = less micro.

Phase C β€” Finite COLLECTION storage. β€Ί REVERTED β€” Refinery/Foundry/Arsenal are no longer drop-offs. The forward-collection capability described below shipped, then was later removed: the Refinery/Foundry/Arsenal no longer carry dropOff/storeCap, isGatherDropOff now resolves to the Command Center alone, and the AI's forward-Refinery placement logic (aiEconomy.js) was deleted along with it. Every gather/haul trip is a straight run to a Command Center again. Left below for the historical record of what Phase C built and why.

  • The forward drop-offs (Refinery/Foundry/Arsenal β€” the pure dropOff buildings, via the new isGatherDropOff) get a finite storeCap intake buffer. A PLAYER gatherer banks its haul into that buffer (capped); a full one is skipped by nearestGatherDrop so the gatherer reroutes to the next collection point (the Command Center never fills). Phase A haulage clears the buffer to the CC automatically. Factories are excluded from gather drop-offs (their store is refined OUTPUT, not a raw-intake bin).
  • The Command Center is the treasury/warehouse β€” effectively unbounded ("very large storage"): haulage delivers there and supply picks up there (nearestCommandCenter), so goods flow through the CC, never sideways between drop-offs.
  • AI-safe by construction: the finite-intake path is gated to owner === "player". An AI drop-off banks straight to the treasury as before, and the AI builds no producers so its workers never haul/supply β€” so the AI economy and the byte-identical determinism/determinism-roster replays are untouched (verified: full suite green).
  • render.js store bar (Phase A) covers forward drop-offs; hud.js shows a selected drop-off's intake buffer + "FULL: gatherers reroute" state. Tests in test/gather.test.js (finite intake + reroute) and test/haul.test.js (AI drop-off stays bufferless).

Known pre-existing issue (surfaced, not caused, by Phase C): two+ workers rapidly cycling a drop-off planted very close to a node can separation-deadlock in the toNode walk (confirmed identical on the pre-Phase-C code). It's a movement/separation bug, out of scope here; a real placement keeps the drop-off far enough from the node to avoid it.

Phase D β€” Freighters join the local logistics chain. βœ… DONE (player-only, Odyssey-only). Revisits item 10 above: a landed freighter (hauler/heavyhauler/bulkfreighter) is now a physical object on the world map, not just a menu-driven hold, in two ways:

  • Workers can load/unload it β€” FERRY (engine/haul.js updateFerry). Right-click a friendly freighter with workers selected (engine/commands.js issueFerryFreighter, input.js) to assign them to it, the same idiom as issueServiceBuilding. A ferry worker keeps carrying the nearest own producer's backlog straight onto the freighter's freight hold β€” the freighter becomes its own collection point, no detour through the Command Center β€” and once there's nothing left to load, draws some of the hold back out and banks it at the treasury instead. Any freighter qualifies, no research needed: this is a worker loading a crate onto a ship, not the ship acting on its own. Shares nearestBacklogProducer with assignHaul (parameterised: a fresh auto-haul still waits for a β‰₯34%-full buffer; a worker already dedicated to a freighter takes any backlog) and the same per-producer haulers cap, so it can't out-compete regular haulage for labour.
  • A freighter can BE the logistics chain β€” AI-LOGISTICS (FREIGHTER_AI_TECH). A new Datacenter tech, freighterai (requires aicores, costs AI Cores to research), unlocks a per-freighter toggle (unit.aiLogistics, HUD button, engine/commands.js issueSetAILogistics). Toggled on, a freighter offers itself to the SAME assignHaul/assignService idle-worker logic every tick (engine/sim.js) β€” it grabs a haul or service job exactly like a worker would, but at its own trip capacity: with no cargoCap of its own, tripCapacity() falls back to the ship's whole cargoHold (250/650/1600 vs. a worker's 10), so one autonomous run clears a backlog a worker would need many trips for. Running this way isn't free: every tick an autonomous job is active it burns AI Cores from the treasury (payAIUpkeep, rate scaled by the ship's own cargoHold β€” a bigger hold costs more to run unmanned). Out of AI Cores, it just pauses in place for the tick (order and cargo untouched) rather than stranding anything; toggling AI-logistics off stands it down immediately. A freighter with no AI Cores in stock also never claims a fresh job in the first place, so it doesn't sit there hogging one of a producer's ≀2 hauler slots while unable to move.
  • Both paths reuse the SAME loadFrom/cargo machinery workers already use (generalised to take a plain commodityβ†’qty store instead of a building, so it works against either a building's store or a freighter's freight) β€” no parallel implementation to keep in sync. freightUsed/freightRoom moved from engine/galaxy.js to engine/entities.js (re-exported from galaxy.js for every existing caller) so engine/haul.js can read a freighter's room without an import cycle (galaxy.js β†’ sim.js β†’ haul.js).
  • Player-only and Odyssey-only by construction (freighters are odysseyOnly, the tech is gated behind the Datacenter/aicores, and the sim only ever offers this to owner === "player") β€” the AI never builds a freighter, so the skirmish AI and its byte-identical replay are untouched. Tests in test/ferry.test.js.

Phase E β€” Command Center zone affinity + a worker REPAIR job. βœ… DONE. Revisits a gap the original four phases didn't cover: on a multi-base empire, every "nearest" job-pick above (nearestBacklogProducer, assignService's factory scan, assignFerry's freighter scan) searched the WHOLE empire from the idle worker's current position with no notion of "which base do I belong to." Once a busy base's own ≀2-per-target caps (MAX_HAULERS/ MAX_SERVERS) filled up, its surplus idle labour got "poached" by whichever OTHER base's job happened to be nearest in that flat global search β€” a routine long, arbitrary cross-map commute, not just an occasional loan. The same gap existed in the auto-repair Mender's roam target pick (engine/sim.js autoRepairRoam), which also scanned globally by worn-first/distance with no zone awareness.

  • Zone affinity (engine/gather.js zoneFirst). A building/unit's "zone" is simply whichever of its owner's Command Centers sits nearest to it β€” computed on demand, never stored/cached, so founding or losing a CC instantly redraws every boundary with nothing to invalidate. zoneFirst runs a caller-supplied scan TWICE: first restricted to the searcher's own zone, and only if that comes back empty, once more with no restriction β€” today's plain global search, now a last resort instead of the routine case. A one-CC game (nearly every skirmish, and Odyssey before a player expands) has exactly one zone, so behaviour there is byte-identical to before this existed β€” verified by a dedicated test alongside the cross-zone ones. nearestBacklogProducer/assignService/assignFerry (engine/haul.js) and the Mender's autoRepairRoam (engine/sim.js, via the new pickRepairTarget below) all adopt it.
  • A worker REPAIR job (engine/repair.js). Buildings could only be healed passively by a Mender in range, or by an autoRepair Mender roaming to the worst-off one β€” nothing stationed labour there when no Mender was around, even though Odyssey structures wear down on their own (updateDecay). A generalist worker can now be assigned a repair order β€” auto (idle workers offer themselves the same zone-first way haul/service already do, after ferry/haul/service in priority) or manual (right-click a damaged own building with workers selected, issueRepairBuilding β€” any completed building qualifies, not just a logistics-buffered one). It walks to the building and patches it at a flat WORKER_REPAIR_RATE (free, like a Mender's heal β€” time, not resources), capped at MAX_REPAIRERS per building so labour spreads out, same shape as the haul/service caps. Buildings only β€” a Mender already covers wounded mobile units, and a stationary "post a request to the nearest CC" model fits a fixed structure, not a unit that might have moved by the time labour arrives; that's a deliberate v1 scope line, not an oversight. pickRepairTarget (zone-first, worst-hp-fraction-first, the shared NEEDS_REPAIR/HEALED hysteresis thresholds) is exported so the Mender's own roam target-pick reuses the exact same selection logic instead of a second copy.
  • AI-safe and deterministic by the same construction as Phases A-D: zone affinity and the repair job are pure functions of state (id-tiebroken, no wall clock), and the Odyssey AI's own worker logistics (engine/aiWorkers.js assignAiLogistics, endless-only) picks up both the zone affinity and the new repair job for free by calling the same engine/haul.js/engine/repair.js functions the player's workers use β€” its skirmish behaviour is untouched (assignAiLogistics is never invoked outside Odyssey). Tests: test/zones.test.js (cross-base zone preference for haul/service/ferry, plus the single-CC byte-identical case) and test/repairJob.test.js (auto/manual assignment, the repair cap, and zone preference).

Phase F β€” home-base override + repairing mobile units too. βœ… DONE. Closes the two items Phase E deliberately left for later.

  • A player-assigned home base (unit.homeCC, engine/commands.js issueSetHomeBase). Zone-first (Phase E) still GUESSED a unit's home zone from raw nearest-CC distance β€” usually right, but the player had no way to override it. Right-clicking a completed own Command Center with eligible units selected (worker, Mender, or a freighter β€” anything that ever consults a zone) now pins unit.homeCC to it; engine/gather.js zoneFirst takes an optional homeId and, when it resolves to a live owned CC, uses it INSTEAD of the distance guess β€” the player decides which base's territory a unit's jobs stay loyal to, not just proximity (directly answering "the player would be in charge of where sufficient resources are, per area"). It's passive: setting it never interrupts whatever order the unit is already running, only what its NEXT idle job search prefers. A stale override (its CC destroyed, or never valid) is ignored, falling straight back to the distance guess β€” self-healing, nothing to clean up. Threaded through every zone-first call site: nearestBacklogProducer/assignService/assignFerry/updateFerry's replan (haul.js), pickRepairTarget (repair.js, so both the worker repair job AND the Mender's own autoRepairRoam honor it). HUD: a selected unit with a home base shows a "🏠 Home base assigned" note and a Clear button; a selected Command Center shows how many units call it home.
  • Refinement β€” an explicit assignment is a HARD boundary, not just a tie-break. The first cut of zoneFirst fell back to a global search whenever the (overridden or guessed) home zone came up empty, no matter which way home was picked. That's right for the DEFAULT distance guess (a mere heuristic β€” widen the search rather than sit idle) but wrong once the player has deliberately assigned a base: a hauler pinned to Command Center A should keep cycling through WHATEVER other jobs A's own zone has to offer, and simply wait when it has none β€” never reach into Command Center B's territory on its own. zoneFirst now tracks whether home came from a valid override or the distance guess, and only widens to the whole empire in the latter case; a pinned unit with an empty zone gets null (no job, stays idle) until the player reassigns it to a different CC (right-click it β€” issueSetHomeBase overwrites the old assignment) or clears it outright (the HUD's "Clear home base" button, or setting homeCC = null β€” "assign to nothing"). This is also exactly why a building-level manual assignment (issueServiceBuilding/issueRepair/issueFerryFreighter, order.manual) was left untouched: it was ALREADY a hard, single-target lock (the worker loops on that one building/freighter forever once done, never auto-reassigned) β€” the contrast is intentional. A CC assignment is a territory (flexible among whatever's inside it); a building assignment is a target (one specific thing, no substitutes). Tests: test/zones.test.js (a pinned empty zone stays idle rather than poaching; reassigning to a different CC immediately redraws the boundary; clearing the override restores the old lenient default).
  • A worker can now repair a wounded mobile UNIT, not just a building. pickRepairTarget already supported includeUnits; assignRepair now leaves it at the default (true) instead of forcing buildings-only, so a worker's auto-assigned or manually-issued (issueRepair, renamed from issueRepairBuilding β€” now takes either a building or unit id) repair job can chase and patch a hurt Ranger/Bastion/etc. the same way it patches a turret β€” walking toward the target's LIVE position each tick, the same idiom the Mender's own roam already uses for a moving target. The order's buildingId field became a generic targetId (resolved via state.js getEntity, building OR unit) to match. A worker still never repairs itself (exclude: unit). This doesn't replace the Mender β€” a Mender still heals passively/for free in an AoE and can roam on its own β€” it just means a damaged unit isn't stranded with nothing to do about it when no Mender is nearby.
  • Tests: test/zones.test.js (a home-base override wins over both raw proximity and the usual nearest-CC guess; a stale override falls back gracefully), test/repairJob.test.js (a wounded unit is now a valid auto-assigned AND chased-and-healed target; a worker never targets itself), and test/commands.test.js (issueSetHomeBase's role gating and its passivity; issueRepair's gating). Same AI-safety/determinism shape as every phase above β€” no wall clock, no unseeded randomness, skirmish AI untouched.

Outcome

All six phases shipped (Phase C was later reverted β€” see above: the Refinery/Foundry/Arsenal are no longer drop-offs, and all collection now runs straight to the Command Center). Storage is finite end-to-end β€” production output (rig + factories) and factory inputs β€” and workers move every good between them (gather β†’ CC β†’ supply β†’ factory β†’ haul β†’ CC). A landed freighter now sits IN that chain too β€” a physical collection point workers can ferry to directly, or (once teched) a large-capacity autonomous hauler in its own right, paid for in AI Cores while it runs. Logistics is a standing demand on labour (and, for an automated fleet, on AI Cores) to the end of the game, with the Command Center as the one bottomless warehouse. On a multi-base empire, every job (haul, service, ferry, repair) is discovered zone-first β€” and the player can pin a unit's home base outright, overriding the distance guess β€” so labour stays loyal to its own base instead of routinely commuting across the map. A worker can now actively repair a damaged building OR a wounded mobile unit, not just a passing Mender. Energy stays a placement decision (grid efficiency), no workers. Everything player-only (or, for zone affinity and repair, ALSO extended to the Odyssey AI's own logistics workers, by construction never the skirmish AI) and deterministic; the AI and skirmish replays are byte-identical.

2.5 Recommended order & risk

Phase A (low risk, player-only, self-contained, immediately satisfying) β†’ Phase B (medium, builds the supply network) β†’ Phase C (higher, touches the AI & market, needs the most careful determinism pass). Ship and play-test each before the next; do not attempt all three in one pass.