Skip to content

Latest commit

 

History

History
287 lines (232 loc) · 11.7 KB

File metadata and controls

287 lines (232 loc) · 11.7 KB

simulator-rust architecture

Living diagrams of the Rust space-economy simulator. For the per-system behavior table, see the "Systems wired" section of CLAUDE.md. For the current improvement backlog, see plan.md.

Update rule: when a phase is added to the tick loop, a new contract kind is added, or a new agent type is introduced, update the affected diagram below in the same PR.


1. Tick loop phase graph

The tick is a fixed sequence of phases with exclusive mutable access to AgentStore. No locks, no intra-tick async. Order is hardcoded in engine::run_tick_loop and must not be reordered casually — several phases depend on the previous phase's apply step (e.g. markets tick after ships+facilities so this tick's supply changes land before price recalc).

flowchart TD
    ctrl[drain control channel<br/>pause/speed/step]
    bodies[bodies: regen resources]
    lod[LOD recompute<br/>every 50 base-ticks]
    ships[ships: advance transit +<br/>observe prices + decide_and_act]
    appS[apply ship commands<br/>dock / supply delta / contract accept]
    drain1[drain materializations /<br/>decommissions / respawns]
    facs[facilities: recipes + mining +<br/>condition + restock + post contracts]
    pops[populations: consume + sentiment +<br/>passenger contracts + migration]
    mkts[markets: produce → consume → emergency →<br/>surplus → match → demand → shortage → price]
    appM[apply market commands<br/>credit transfers, supply deltas]
    drain2[drain pending queues again]
    facts[factions: tax + subsidy + construction]
    snap[snapshot cache rebuild]
    bcast[broadcast to WS clients]
    persist[emit persist writes to worker]

    ctrl --> bodies --> lod --> ships --> appS --> drain1 --> facs --> pops --> mkts --> appM --> drain2 --> facts --> snap --> bcast --> persist
    persist -.->|next tick| ctrl
Loading

2. Runtime data ownership

sim_core is pure (no tokio/sqlx/axum). Two binaries embed it.

client_bevy (canonical) — embeds sim_core directly. The tick loop runs as a Bevy system on FixedUpdate at 125 ms × tick_mult; it holds &mut AgentStore exclusively for that frame. egui panels and the render pipeline read the hecs World from a Bevy Res<Sim> — no snapshot layer, no serialization, no socket.

sim_server (headless / CI) — wraps the engine in axum + sqlx + tokio for the WS / HTTP scenarios scripts and CI use. Same engine, same apply helpers (pub fn in sim_server::engine, shared via [lib]); just a different shell.

Persistence is a sibling tokio task consuming a buffered mpsc channel so disk IO never stalls the tick — the persistence worker isn't yet wired into client_bevy (load-on-Start works; mid-game autosave is deferred), but the architecture is the same.

flowchart LR
    subgraph bevy[client_bevy<br/>canonical client]
        direction TB
        ui[egui panels]
        render[Bevy render systems<br/>Time::Fixed::overstep_fraction lerp]
        tick[tick_sim<br/>FixedUpdate, exclusive &mut AgentStore]
        cam[orbit camera + audio]
    end
    subgraph server[sim_server<br/>headless / CI]
        direction TB
        ws[ws::handler]
        http[http::api + admin]
        runloop[run_tick_loop<br/>tokio interval]
        snap[(SnapshotCache<br/>DashMap, lock-free)]
    end
    subgraph core[sim_core — pure]
        store[AgentStore = hecs::World + ContractBoard]
        cmds[CommandBuffer]
        ai[ai: BT + utility + considerations + GOAP]
        eco[economy: prices, recipes, contracts, missions]
    end
    subgraph db_layer[sim_db]
        seed[seeder + ECS loader]
        pw[PersistenceWorker<br/>5s batch, mpsc dedup]
        sqlite[(SQLite WAL)]
    end

    ui --> tick
    render --> tick
    tick --> store
    tick --> cmds
    tick --> seed
    seed --> sqlite
    runloop --> store
    runloop --> cmds
    runloop --> snap
    runloop -->|DbWrite mpsc| pw --> sqlite
    ws -->|read only| snap
    http -->|read only| snap
    http -->|SimCommand| runloop
    store -.uses.- ai
    store -.uses.- eco
Loading

2.5 client_bevy state machine

Discrete scenes — the title screen does NOT have the world hiding behind it. OnEnter(InGame) opens the chosen DB, seeds if empty, populates the AgentStore, and spawns Bevy mirror entities (ships / facilities / bodies / suns). OnExit(InGame) despawns every GameWorld-tagged entity and resets Sim::default() so a different universe can boot next Start.

stateDiagram-v2
    [*] --> MainMenu: Bevy Startup
    MainMenu --> NewUniverseDialog: Start
    NewUniverseDialog --> InGame: Confirm name<br/>(sanitised + collision-suffixed)
    NewUniverseDialog --> MainMenu: Cancel / Esc
    MainMenu --> SaveList: Load Game
    SaveList --> InGame: Load (sets SelectedSavePath)
    SaveList --> MainMenu: ✕ / Esc
    MainMenu --> [*]: Quit
    InGame --> Pause: Esc / P / Menu button<br/>(only when no panels open)
    Pause --> InGame: Resume
    Pause --> MainMenu: Return to Main Menu<br/>(teardown_world)
    Pause --> [*]: Quit

    note right of InGame
      OnEnter: load_world,
        spawn_mirror_entities (.after(load_world)),
        spawn_system_suns,
        reset_camera_for_game
      OnExit: cleanup_game_world,
        teardown_world (reset Sim::default(),
        clear Retirements/EventsLog/SelectedSavePath)
    end note

    note left of Pause
      Esc closes panels LIFO via OpenPanelStack;
      Pause menu only opens when stack is empty.
      P jumps directly to Pause regardless.
    end note
Loading

3. Ship decision stack

Strategic layer (utility + priority waterfall in plan_goal) picks which goal. Tactical layer (BtNode<ShipAction> resumable tree) handles how to execute it. Dispatch is via a closure in execute_ship_action that holds the mutable component borrows for this ship, keeping the BT engine generic and reusable.

flowchart TD
    tick[decide_and_act per active ship]
    snaps[build per-tick snapshots:<br/>loc→system, dock congestion, shipyard stock]

    plan[plan_goal — priority waterfall]
    rest{crew morale<br/>below rest trigger?}
    fuel{fuel<br/>critical?}
    sell{cargo<br/>onboard?}
    retro{retrofit points<br/>+ reachable yard?}
    elective[elective band:<br/>score fulfill_delivery vs trade_run<br/>via ln profit × 1+prefer_goals]
    idle[smart idle:<br/>explore or station-seek or hold]

    bt[[build BtNode ShipAction tree]]
    exec[execute_ship_action via closure dispatch]
    emit[emit SimCommand<br/>cross-entity effects only]

    tick --> snaps --> plan
    plan --> rest --yes--> bt
    rest --no--> fuel --yes--> bt
    fuel --no--> sell --yes--> bt
    sell --no--> retro --yes--> bt
    retro --no--> elective --> bt
    plan -.fallback.-> idle --> bt
    bt --> exec --> emit
    emit -->|applied next phase| plan
Loading

4. Economic feedback loop

The living-economy loop is genuinely closed: shortages propagate to contracts, contracts pull ships, deliveries restore supply, and factions step in (subsidies then construction) when market incentives alone can't close the gap.

flowchart LR
    body[celestial body<br/>resources regen]
    mine[mining_rig facility]
    mkt[local market<br/>supply → coverage → price]
    short[shortage_ticks++<br/>when coverage < 0.1]
    facPost[facility posts<br/>Delivery contract]
    popPost[population posts<br/>Passenger / Food contract]
    sub[faction posts<br/>Subsidy 1.25× – 3×]
    cons[construction:<br/>new facility spawned]
    board[ContractBoard]
    ship[ship plan_goal picks contract<br/>gated by rep + sensor range]
    deliver[deliver → pay + supply bump<br/>via SimCommand apply phase]
    recipe[facility recipes consume supply]

    body --> mine --> mkt
    recipe --> mkt
    mkt --> short
    short --> facPost --> board
    short --> popPost --> board
    short -.600 ticks.-> sub --> board
    short -.very long.-> cons
    board --> ship --> deliver --> mkt
    deliver --> recipe
Loading

5. Contract lifecycle

All six contract kinds (Delivery / Courier / Passenger / Subsidy / Supply / CrewMission) share this lifecycle. Reputation gates start at 0 / 50 / 70 depending on kind and decay −10 rep per 100 ticks until public. Payment escalates +5% per 50 ticks (cap +100%) to incentivise fulfilment of stale demand.

stateDiagram-v2
    [*] --> Posted: facility/pop/faction posts
    Posted --> Posted: age_escalate_payment<br/>+5%/50 ticks, cap +100%
    Posted --> Posted: age_open_reputation<br/>-10 rep/100 ticks → public
    Posted --> Accepted: ship meets rep + sensor gate +<br/>profit > cost estimate
    Posted --> Expired: TTL lapses without renewal
    Expired --> Posted: renew (≤3 times, +25% pay, -15 rep)
    Accepted --> PickedUp: cargo loaded at source
    PickedUp --> Delivered: cargo unloaded at dest
    Delivered --> Paid: apply phase pays ship + bumps supply
    Paid --> [*]
    Accepted --> Posted: ship dies / re-plans → orphan release
Loading

6. Market 8-phase tick

Each market runs eight ordered sub-phases per tick. The coverage model treats supply as a buffer (not a ratio) so stockpiles stabilise prices rather than depress them. Urgency multiplier ramps 1.0 → 2.5 over a 50-tick shortage window so persistent scarcity keeps pressure climbing.

flowchart LR
    p1[produce<br/>MarketFlow.production]
    p2[consume<br/>update consumption_window]
    p3[emergency orders<br/>low supply + positive demand]
    p4[surplus orders<br/>supply > demand × 5]
    p5[match orders<br/>midpoint - spread/2]
    p6[update demand<br/>rolling window]
    p7[track shortage<br/>shortage_ticks++]
    p8[price recalc<br/>coverage × elasticity + urgency]

    p1 --> p2 --> p3 --> p4 --> p5 --> p6 --> p7 --> p8
Loading

7. LOD gating

Observer-driven distance tiers assign each agent a tick divisor (1, 2, 5, 10, 20, 50). A ship outside all observer radii fires every 50 ticks; per-tick accumulators (fuel burn, crew hunger) scale by skipped_ticks so total state change over wall-clock matches. Facilities and populations carry AgentLod but their tick phases don't gate on it yet — see plan.md Bundle 1.5. Markets are never LOD'd because ordering sensitivity makes skipped ticks unsafe there.

flowchart TD
    obs[observer positions<br/>from WS hello]
    dist[distance² to nearest observer]
    tier[divisor ∈ 1,2,5,10,20,50]
    fire{tick >= next_tick?}
    skip[skip this tick]
    run[run ship phases<br/>scale fuel burn + crew needs by skipped_ticks]
    fac[facilities + populations<br/>carry AgentLod but NOT gated]
    mkt[markets<br/>never LOD'd — ordering risk]

    obs --> dist --> tier --> fire
    fire --no--> skip
    fire --yes--> run
    fac -.unused.-> tier
    mkt -.excluded by design.-> tier
Loading

8. Persistence + command flow

Cross-entity effects go through SimCommand (enum, ~40 variants) collected into a CommandBuffer per tick. The apply phase drains the buffer and executes atomically. Dirty agents emit DbWrite to an mpsc channel; the persistence worker dedups by (namespace, id) and flushes to SQLite every 5 s (or on-demand via admin save).

flowchart LR
    phase[tick phase writes SimCommand<br/>to CommandBuffer]
    apply[apply phase drains buffer,<br/>executes cross-entity effects]
    dirty[dirty agents → DbWrite]
    ch[mpsc channel]
    pw[PersistenceWorker]
    dedup[dedup by namespace+id]
    flush[5s batch flush<br/>one tx per sweep]
    sqlite[(SQLite WAL)]

    phase --> apply --> dirty --> ch --> pw --> dedup --> flush --> sqlite
Loading

Cross-references

  • CLAUDE.md — Systems wired table (behavior-per-module), crate layout, conventions.
  • SESSION-HANDOFF.md — fresh-session orientation.
  • plan.md — bundled improvement roadmap.