Spaces:
Paused
Download docs/logistics-design.md from Almaatla/SpaceCities: direct link, hf CLI and curl.
- Browser
- Download file 26 kB
-
https://huggingface.co/spaces/Almaatla/SpaceCities/resolve/d19cbc4fab45274de920705d8b5b01da170c7e89/docs/logistics-design.md
- Command line
-
hf download hf://spaces/Almaatla/SpaceCities@d19cbc4fab45274de920705d8b5b01da170c7e89/docs/logistics-design.md
-
curl -L -o logistics-design.md https://huggingface.co/spaces/Almaatla/SpaceCities/resolve/d19cbc4fab45274de920705d8b5b01da170c7e89/docs/logistics-design.md
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.powerDrawscales 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
- 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.
- 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. - 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'snearestDropoffpattern +hashStr. - Save/load. New
store/inputbuffers on buildings must be coerced inpersist.js cleanEntity(COM-valid keys, β₯0, clamped to cap) exactly likefreightand the rig'sdigProgress. - 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".
- 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").
- 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".
- 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). - 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".
- 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 helpersstoreTotal/storeRoom/storeCapOf(entities.js). updatePlasmaRigbanks each dig intostore(topping off the final dig to exactlystoreCap, 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: coercestore(COM-valid, β₯0, clamped to cap); strip the transienthaulerstally 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(+ rewrittentest/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
inputlarder (both cap 80, viastoreCapOf/inputCapOfβ any building with arecipe).updateProductiondraws 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
supplyleg (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.jscoerces theinputbuffer and strips the transientsupplierstally;hud.jsfactory 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.jschain tests to the buffered model; added supply tests totest/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
dropOffbuildings, via the newisGatherDropOff) get a finitestoreCapintake buffer. A PLAYER gatherer banks its haul into that buffer (capped); a full one is skipped bynearestGatherDropso 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 (theirstoreis 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-identicaldeterminism/determinism-rosterreplays are untouched (verified: full suite green). render.jsstore bar (Phase A) covers forward drop-offs;hud.jsshows a selected drop-off's intake buffer + "FULL: gatherers reroute" state. Tests intest/gather.test.js(finite intake + reroute) andtest/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 asissueServiceBuilding. A ferry worker keeps carrying the nearest own producer's backlog straight onto the freighter'sfreighthold β 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. SharesnearestBacklogProducerwithassignHaul(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-producerhaulerscap, 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(requiresaicores, 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 SAMEassignHaul/assignServiceidle-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 nocargoCapof its own,tripCapacity()falls back to the ship's wholecargoHold(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 owncargoHoldβ 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'sstoreor a freighter'sfreight) β no parallel implementation to keep in sync.freightUsed/freightRoommoved fromengine/galaxy.jstoengine/entities.js(re-exported from galaxy.js for every existing caller) soengine/haul.jscan 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 toowner === "player") β the AI never builds a freighter, so the skirmish AI and its byte-identical replay are untouched. Tests intest/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.zoneFirstruns 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'sautoRepairRoam(engine/sim.js, via the newpickRepairTargetbelow) all adopt it. - A worker REPAIR job (
engine/repair.js). Buildings could only be healed passively by a Mender in range, or by anautoRepairMender 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 arepairorder β 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 flatWORKER_REPAIR_RATE(free, like a Mender's heal β time, not resources), capped atMAX_REPAIRERSper 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 sharedNEEDS_REPAIR/HEALEDhysteresis 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 sameengine/haul.js/engine/repair.jsfunctions the player's workers use β its skirmish behaviour is untouched (assignAiLogisticsis never invoked outside Odyssey). Tests:test/zones.test.js(cross-base zone preference for haul/service/ferry, plus the single-CC byte-identical case) andtest/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 pinsunit.homeCCto it;engine/gather.js zoneFirsttakes an optionalhomeIdand, 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 ownautoRepairRoamhonor 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
zoneFirstfell back to a global search whenever the (overridden or guessed) home zone came up empty, no matter which wayhomewas 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.zoneFirstnow tracks whetherhomecame 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 getsnull(no job, stays idle) until the player reassigns it to a different CC (right-click it βissueSetHomeBaseoverwrites the old assignment) or clears it outright (the HUD's "Clear home base" button, or settinghomeCC = 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.
pickRepairTargetalready supportedincludeUnits;assignRepairnow leaves it at the default (true) instead of forcing buildings-only, so a worker's auto-assigned or manually-issued (issueRepair, renamed fromissueRepairBuildingβ 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'sbuildingIdfield became a generictargetId(resolved viastate.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), andtest/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.