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.