Skip to content

Latest commit

 

History

History
538 lines (428 loc) · 29.9 KB

File metadata and controls

538 lines (428 loc) · 29.9 KB

THE MASTER'S SHADOW — Technical Architecture

A shade cannot cheat the dice it did not roll.

This document describes the technical architecture of The Master's Shadow, a C++17 / raylib gothic narrative roguelike. It is written for engineers who need to understand, extend, or audit the codebase. Everything below is grounded in the source: file and symbol names are real, constants are quoted from src/core/constants.hpp, and the render numbers come from src/render/viewport.hpp and src/game/app.cpp.

The guiding principle is a hard separation between what the game is (a pure, deterministic simulation) and how the game looks and sounds (a raylib presentation layer). The Master — the pursuing antagonist — is a pluggable agent bolted onto that simulation through the exact same action API the player uses. He is "restricted just like the player," and the type system enforces it.


1. The Three-Layer + Data Design

The project is deliberately split so that the rules can be tested, replayed, and balanced without ever opening a window. There are three code layers and one content layer.

Layer Artifact raylib? Purpose
Core simulation ms_core (static lib) No The deterministic heart: authoritative GameState, RulesEngine, RNG, world gen, magic, AI, agent, save/replay.
Presentation masters_shadow (exe) Yes Screens, HUD, scene rendering, audio, particles, input. Reads state, issues Actions, draws.
Agent inside ms_core + agent_engine/ (Python) No The Master's brain: default deterministic ACO, or an external LLM via a validated, async bridge.
Content data/ (flat records) Lineages, spells, enemies, events, chaos, endings, items — git-native, human-editable text.

1.1 ms_core — the raylib-free deterministic simulation

ms_core is a STATIC library built from src/core, src/world, src/magic, src/ai, src/agent, and src/save (see CMakeLists.txt). It does not link raylib and does not include raylib.h. This is a load-bearing constraint: because the simulation has no graphics dependency, it can be linked directly into headless test and balance executables that run in CI with no display.

The library exposes exactly one authoritative mutable object — GameState (src/core/game_state.hpp) — owned by exactly one gatekeeper — RulesEngine (src/core/rules_engine.hpp). Nothing outside that engine's API is permitted to mutate simulation state.

1.2 masters_shadow — the presentation executable

The game executable is built from src/main.cpp plus src/render, src/ui, src/audio, and src/game, and links ms_core and raylib. This layer owns the window, the fixed-resolution render target, the screen state machine, and all input. It is a consumer of ms_core: it holds a RulesEngine, reads engine->state() for display, and prefers submitting Actions via apply() for anything that should be journaled.

Hybrid mutation model (honest). The pure ideal is "presentation only reads state and issues Actions." In practice Phase 2/3 still own some gameplay mutations through RulesEngine::mutable_state() (resource spends, buff decay, camp recovery, chaos side-effects, combat flag charges). Those paths use the engine RNG when rolls matter, but they are not appended to the action journal. Consequences:

  • The golden guarantee (seed, lineage, journal) → state_hash is proven for the core journal path (ms_headless --selftest, baseline 16425e914e1636ca).
  • Full player runs are not pure-journal-replayable today.
  • Continue therefore writes MSSNAP state snapshots (src/game/run_save.cpp), not journal replay alone. The older MSSAVE journal format remains for tools and core replay.

The long-term direction is to promote high-churn screen mutations into additive ActionTypes (see docs/MUTABLE_STATE_INVENTORY.md). Setup / phase entry still correctly uses mutable_state() for placing units and seeding carryover pools.

Combat is a parallel presentation sim (own HP/shield, side-stream RNG); it only touches GameState for inventory flags and spell/item effects.

1.3 The agent layer

The Master is an IAgent (src/agent/agent.hpp). The default implementation, ACOMasterAgent, is a deterministic ant-colony-optimization / pheromone brain that lives entirely inside ms_core. Two alternative implementations connect to an external LLM — LLMAgent (raw OpenAI-compatible HTTP) and BridgeAgent (the agent_engine/ Python package that drives LM Studio). All of them are selected by a factory and are interchangeable behind the same interface. See §6.

1.4 The content layer (data/)

Game content is authored as flat key: value records, parsed by ContentDB (src/core/content_db.hpp). Records within a file are separated by a line containing only ---, so YAML-frontmatter markdown drops in cleanly. The live bundle (validate with python tools/okf_validate.py):

data/
├── lineages/        injected bloodline traits (per-mille ints)
├── enemies/         injected bestiary stats
├── chaos/           apex chaos base weights
├── tuning/          global knobs + Phase-1 economy
├── world/           terrain move-cost + encounter base
├── spells/          injected SpellRow catalogue (combat + field)
├── items/           injected ItemRow catalogue (satchel)
├── difficulty/      Normal / Hardcore rows (row 0 = selftest literals)
├── reagents/        tag-synergy alchemy (BrewCombine)
├── traps/           Architect trap table
├── master_spells/   Master counter-alchemy
├── reputation/      authored faction events
├── events/          Phase-2 wild events (journaled RNG in screens)
├── recipes/         app-layer brew recipes
├── strings/         uistring via tr()
├── barks/           Master taunt corpora
├── camp/            night-camp dreams
├── endings/         endings + lineage codas
├── lore/            codex fragments (presentation)
└── achievements/    honours definitions

ContentDB::load_dir recursively loads *.md / *.txt, indexes records by id, and offers typed accessors (get, geti, getf, getb, getlist) plus lookups by id and by type. Core-consumed numeric tables are injected as frozen POD structs (GameTables) before the first apply(); those fields must be integers (per-mille for former floats). See docs/MODDING.md.


2. The Determinism Model

Reproducibility is the spine of this project. The design goal is stated in RulesEngine's header comment: (seed, lineage, journal) reproduces an identical GameState — the guarantee that the Master cannot cheat and that any run is auditable and replayable.

2.1 One authoritative RNG stream

RulesEngine owns a single Rng rng_ (a PCG32 stream, src/core/rng.hpp), seeded from the game seed in its constructor:

RulesEngine::RulesEngine(uint64_t seed, Lineage lineage)
    : state_(GameState::new_game(seed, lineage)), rng_(seed) {}

The rule: every player-affecting roll must go through eng()->rng(). Skill checks, forage yields, trace decay, weather countdowns, chore outcomes — all of them draw from this one stream inside RulesEngine::dispatch. Because the sequence of draws is a pure function of the sequence of actions, two engines fed the same actions in the same order will draw the same numbers and land in the same state. Presentation code that needs randomness for a decision that changes the simulation is required to route the resulting change back through an Action, so the draw that matters happens on the engine stream — not on some ad-hoc rand() in a screen.

2.2 The replay journal

Every action that passes the legality gate is appended to a journal:

RulesEngine::ApplyResult RulesEngine::apply(const Action& a) {
    if (!is_legal(a)) return ApplyResult{false, false, {}, "illegal"};
    ApplyResult res = dispatch(a);
    journal_.push_back(a);   // ← the audit trail
    return res;
}

Action (src/core/action.hpp) is intentionally compact and serialisable: a stable ActionType enum plus four generic ints (a, b, c, d), a string s, and a by_master flag. The comment on the enum is explicit that "enum values are stable (save/replay format)" — you may append new action types but must not renumber existing ones. A full run is therefore just its seed, its lineage, and its ordered list of actions.

The static replay method reconstructs state from nothing but those three inputs:

GameState RulesEngine::replay(uint64_t seed, Lineage lineage,
                              const std::vector<Action>& journal) {
    RulesEngine e(seed, lineage);
    for (const auto& a : journal) e.apply(a);
    return e.state();
}

Note that replay constructs a fresh RNG from the seed — the RNG state is not serialized, because it is fully determined by the seed and the action sequence.

2.3 state_hash — the fingerprint

GameState::state_hash() (src/core/game_state.cpp) folds every field of the state into a single 64-bit FNV-1a value using the incremental Hasher (src/core/hashing.hpp). Two properties make this a reliable determinism oracle:

  • Fixed fold order. Fields are mixed in a hand-written, order-fixed sequence (seed, turn, phase, day segment, then player, master, world, and finally flags). Because flags is a std::map<std::string,int>, it iterates in sorted-key order, so even the dynamic flag set hashes deterministically.
  • Totality. Resources, magic paths, trace/echo/bond/parasite/suspicion, hex positions, skills, reputation, weather, and every flag are included. Nothing that can affect play is left out of the fingerprint.

The headless self-test (§7) runs the same seeded policy twice and asserts the two state_hash results are byte-identical.

2.4 Why war_rng_ is a separate side-stream

Phase 2 stages a crowd-simulated faction war (Rebels, Loyalists, Trolls) contesting the wood, plus procedural encounter gating. Those systems consume a lot of random numbers, but they do not directly determine the outcome of the escape — they are ambient world simulation and encounter gating.

Rather than pollute the authoritative engine stream with these draws (which would make the (seed, lineage, actions) → state_hash relationship depend on how many warband ticks happened to fire), Phase2Screen keeps a dedicated side-stream:

Rng war_rng_{1};
...
war_rng_.reseed(ctx_->seed ^ 0xBA771EULL);   // src/game/phase2.cpp

Seeding it from ctx_->seed ^ 0xBA771E keeps the faction war itself reproducible for a given seed, while isolating its consumption from the core simulation's RNG. The faction war and encounter rolls (war_.step(..., war_rng_), war_rng_.next_double() < encounter_chance(...)) draw only from this side-stream. The division of labor is the rule:

All player-affecting rolls go through eng()->rng(). The faction war / encounter gating — and only that — draws from war_rng_.

This is why the determinism guarantee is scoped to the core state hash: the engine stream is the source of truth, and the war side-stream is a reproducible-but-separate flourish that cannot perturb it.


3. Module Map

The src/ tree divides cleanly along the layer boundary. Everything on the left of the diagram is ms_core (no raylib); everything on the right is the presentation executable (links raylib).

                              THE MASTER'S SHADOW — MODULE MAP

  ┌───────────────────────────── ms_core (static lib, NO raylib) ─────────────────────────────┐
  │                                                                                            │
  │   core/                     world/                  magic/            ai/                  │
  │  ┌──────────────────┐      ┌───────────────────┐  ┌──────────────┐  ┌──────────────────┐  │
  │  │ game_state        │      │ hexmap  forestgen │  │ lineage       │  │ master_ai (ACO)  │  │
  │  │ rules_engine  ◀───┼──────┤ pathfind flowfield│  │ apex          │  │ pursuit_sim      │  │
  │  │ action  rng       │      │ bestiary faction_ │  │ (bloodlines,  │  │ (headless chase, │  │
  │  │ skill_check       │      │ war               │  │  clash, chaos)│  │  balance)        │  │
  │  │ content_db        │      └───────────────────┘  └──────────────┘  └──────────────────┘  │
  │  │ event_bus hashing │                                                                     │
  │  │ hex types const.  │      agent/                            save/                        │
  │  └──────────────────┘      ┌────────────────────────────┐    ┌───────────────┐            │
  │          ▲                 │ IAgent  ACOMasterAgent      │    │ save (journal │            │
  │          │                 │ LLMAgent  BridgeAgent  ─────┼──▶ │  + state I/O) │            │
  │          │                 └────────────────────────────┘    └───────────────┘            │
  └──────────┼─────────────────────────────────────────────────────────┬──────────────────────┘
             │  Actions (submit)          state() (read-only)           │
             │                                                          │  popen / async
  ┌──────────┼──────────── masters_shadow (exe, links raylib) ─────────┐│  ┌────────────────────┐
  │          │                                                         ││  │ agent_engine/       │
  │   game/  │                        render/          ui/    audio/   │└─▶│ run.py config.py    │
  │  ┌───────┴──────────┐  ┌────────────────────┐  ┌──────────┐ ┌────┐ │   │ lmstudio_client.py  │
  │  │ app (loop, RT)   │  │ viewport (vw/vh,   │  │ hud text │ │audio│ │   │ master_agent.py     │
  │  │ screen  title    │  │  virtual_mouse)    │  │ widgets  │ └────┘ │   │  → LM Studio (local │
  │  │ lineage_select   │  │ scene  assets      │  │ palette  │        │   │     LLM, OpenAI-     │
  │  │ phase1/2/3 combat│  │ particles          │  └──────────┘        │   │     compatible)     │
  │  └──────────────────┘  └────────────────────┘                     │   └────────────────────┘
  │            │  main.cpp → GameApp::run()                            │
  └────────────┼─────────────────────────────────────────────────────┘
               │
       ┌───────┴────────┐   ┌──────────────────────┐   ┌─────────────────────────┐
       │ masters_shadow │   │ ms_headless (ms_core)│   │ ms_tests (ms_core +     │
       │  (game exe)    │   │ selftest/balance/apex│   │  doctest, 46 tests)     │
       └────────────────┘   └──────────────────────┘   └─────────────────────────┘

Core (src/core) — the simulation kernel: GameState, RulesEngine, Action, the Rng PCG32 stream, skill_check (d20 resolution), ContentDB, the EventBus (fire-and-forget UI notifications), the FNV-1a Hasher, hex math, shared types, and centralized constants.

World (src/world) — procedural forest generation, hex map, A* pathfinding and flow fields, the bestiary, and the crowd-simulated faction_war.

Magic (src/magic)lineage (the six bloodlines and their signature mechanics) and apex (the Phase-3 sympathetic-bond clash, nine chaos events, ritual inversion, and ending determination).

AI (src/ai)master_ai, the deterministic ACO/pheromone MasterBrain, and pursuit_sim, a self-contained headless chase used by tests and the balance sweep.

Agent (src/agent) — the IAgent interface and its three implementations, plus the factory make_master_agent.

Save (src/save) — persistence of the run (seed, lineage, journal, state).

Presentation (src/render, src/ui, src/audio, src/game) — the fixed-resolution render pipeline (viewport, scene, assets, particles), HUD / text / widgets / palette, the ElevenLabs-backed audio system, and the game screen state machine (app, screen, title_screen, lineage_select, phase1/phase2/phase3, combat).


4. Action-Based Data Flow

The entire game changes state through one funnel. Screens do not mutate; they request.

   ┌──────────┐   Action a   ┌──────────────────┐  is_legal(a)?   ┌───────────────┐
   │  Screen  │─────────────▶│   RulesEngine    │────────────────▶│  legality gate│
   │ (phase1/ │              │   ::apply(a)     │                 └───────┬───────┘
   │  2/3, …) │◀─────────────│                  │                    ok / │ reject
   └──────────┘  ApplyResult └──────────────────┘                        ▼
        ▲   read-only              │ dispatch(a)              ┌──────────────────────┐
        │   state()                ▼                          │ GameState mutators    │
        │                 ┌──────────────────┐  clamped only  │ advance_clock/adjust_ │
        └─────────────────│  EventBus emit   │◀───────────────│ trace/echo/bond/…     │
             (HUD, toasts)└──────────────────┘   + journal.push└──────────────────────┘
  1. A screen builds an Action describing intent — e.g. Action::move(dir), Action::end_turn(), or a hand-filled CastSpell / Forage / CounterRitual.
  2. RulesEngine::apply validates it via is_legal. Illegal actions are rejected without touching state (Move needs energy, CastSpell needs mana ≥ cost, an ended game only accepts Noop). This is the same gate the Master's actions pass through — the agent literally cannot submit a move the player couldn't.
  3. dispatch applies the effect, drawing any randomness from rng_ and mutating GameState only through its clamped mutators (advance_clock, adjust_trace, adjust_echo, adjust_bond, adjust_parasite, adjust_suspicion, adjust_reputation, set_flag). Every one of these clamps to the ranges in constants.hpp, so state can never drift out of bounds.
  4. The action is journaled and side effects are announced on the EventBus (Ev::Moved, Ev::SkillChecked, Ev::ClockAdvanced, …). The EventBus is a one-way notification channel for the presentation layer — HUD flashes, toasts, audio cues. It carries no authority; it cannot change state.
  5. The screen reads back engine->state() (const) to redraw, and inspects the returned ApplyResult (ok, had_check, check, message) for immediate feedback such as the die roll on a skill check.

Because the only write path is apply → dispatch → clamped mutators, and the only read path is const GameState& state(), the invariant holds: nothing mutates simulation state outside the engine API. This is what makes the replay/hash guarantee sound — there is no back door for a screen (or an agent) to poke a field.


5. The Fixed 1280×720 Virtual-Resolution Render Pipeline

All presentation is authored against a single virtual canvas so that layout is pixel-identical on every monitor and DPI. The constants live in src/render/viewport.hpp:

constexpr int kVW = 1280;
constexpr int kVH = 720;
inline int vw() { return kVW; }
inline int vh() { return kVH; }

Every screen, HUD element, and scene lays out in these virtual coordinates via vw() / vh() — never against the live window size.

5.1 Render-to-texture, then letterbox

GameApp::run (src/game/app.cpp) drives the loop. Once, at startup, it allocates a RenderTexture2D at the virtual resolution:

RenderTexture2D target = LoadRenderTexture(kVW, kVH);
SetTextureFilter(target.texture, TEXTURE_FILTER_BILINEAR);

Each frame:

  1. Recompute the transform. From the current window size it derives a uniform scale that fits the 1280×720 canvas inside the window, and centers it (letterbox offsets ox, oy). These are stored on the process-wide Viewport singleton:

    float scale = fminf(ww / (float)kVW, wh / (float)kVH);
    Viewport& vp = viewport();
    vp.scale = scale;
    vp.ox = (ww - kVW * scale) * 0.5f;
    vp.oy = (wh - kVH * scale) * 0.5f;
  2. Render the world into the target at native virtual resolution (BeginTextureMode(target)screen_->draw() → optional pause overlay → EndTextureMode()).

  3. Present, scaled and letterboxed. The texture is blitted to the backbuffer with DrawTexturePro, using a source rectangle with a negated height ({0,0,kVW,-kVH}) to flip the bottom-up GL texture right way up, into a destination rectangle offset by the letterbox margins and scaled to fit.

5.2 virtual_mouse() closes the loop

Because rendering is scaled and offset, raw mouse coordinates are meaningless to virtual-space UI. virtual_mouse() (src/render/viewport.cpp) inverts the transform so input lands where the pixels are:

Vector2 virtual_mouse() {
    Vector2 m = GetMousePosition();
    const Viewport& v = viewport();
    float s = v.scale > 0 ? v.scale : 1.0f;
    return Vector2{(m.x - v.ox) / s, (m.y - v.oy) / s};
}

All widget hit-testing uses virtual_mouse(), so a button at virtual (640, 360) is clickable at the same logical spot regardless of window size, fullscreen, or DPI.

5.3 Assets

The Assets singleton (src/render/assets.hpp) caches POINT-filtered pixel-art textures and the three UI fonts (FontRole::Display, Body, Mono), resolving the assets/ directory relative to wherever the game was launched. Missing textures return a visible magenta placeholder rather than crashing — deliberately loud, so a missing asset is impossible to miss during development. The window itself is created with FLAG_MSAA_4X_HINT | FLAG_VSYNC_HINT | FLAG_WINDOW_RESIZABLE, a 60 FPS target, and SetExitKey(KEY_NULL) so Escape opens the pause menu instead of killing the run mid-game.


6. The Pluggable Master Agent

The Master's decision-making is abstracted behind IAgent (src/agent/agent.hpp). Whatever brain is plugged in sees only a bounded, honest observation and may choose only among legal moves.

  • AgentObs hands the agent a MasterView and the pre-computed list of legal_moves (walkable neighbours). The header note is blunt about intent: this is "what keeps a plugged-in LLM restricted just like the player."
  • AgentAction is one of Move (to a target hex), Forage, or Rest, plus an optional in-character narration line the Master may speak.

Three implementations, one factory (make_master_agent, selected by MS_MASTER_AGENT):

Kind Class Behavior
aco (default) ACOMasterAgent Deterministic ACO/pheromone MasterBrain — offline, reproducible, always available.
openai LLMAgent Calls an OpenAI-compatible /chat/completions endpoint over HTTP; validates the reply against legal moves; falls back to ACO. Config: MS_LLM_URL, MS_LLM_MODEL, MS_LLM_KEY.
llm / cosysim BridgeAgent Drives the agent_engine/ Python bridge (LM Studio) asynchronously.

Async, validated, fallback-safe. BridgeAgent::decide never blocks the frame: the ACO brain plays instantly while a background std::async thread asks the local model for its (possibly slower) decision. When a reply arrives it is re-validated against the current legal moves before use; anything illegal, empty, or late is discarded and ACO stands in. The Python side (agent_engine/config.py) is careful never to force-load a model — it only uses a model LM Studio already has resident, honoring MS_LLM_MODEL only when that model is loaded (or MS_LLM_FORCE=1), and reading MS_LLM_URL (default http://127.0.0.1:1234/v1) and MS_LLM_KEY. The net effect: an LLM can voice and drive the Master, but it can never cheat, never stall the game, and never crash the run — every path degrades to the deterministic brain.


7. Build Targets and the Headless Harness

CMakeLists.txt (top level) defines the library and three executables. It fetches and statically builds raylib 5.5 via FetchContent, and on MinGW statically links the runtime (-static -static-libgcc -static-libstdc++) so the shipped binaries and CI runs need no external DLLs.

Target Sources Links Role
ms_core core, world, magic, ai, agent, save Deterministic simulation static lib (no raylib).
masters_shadow main.cpp + render, ui, audio, game ms_core, raylib The playable game executable.
ms_headless src/headless_main.cpp ms_core Determinism / balance / apex harness (no raylib).
ms_tests tests/*.cpp ms_core 46 doctest unit tests; registered with CTest.

7.1 ms_headless — self-test, balance, apex

src/headless_main.cpp links only ms_core, so it runs anywhere with no display. It offers three modes:

  • --selftest — runs a seeded stochastic self-play policy (run_sim) twice and asserts the two state_hash fingerprints match. This is the determinism guarantee reduced to a single PASS/FAIL:

    selftest seed=12345 turns=40  h1=…  h2=…  PASS
    

    The policy exercises chores, puzzles, spells, forage, rest, movement, counter-ritual, and Master research/movement — a broad slice of the action space — and every choice comes from an independent decide RNG seeded off the game seed, so the whole run is reproducible.

  • --balance N [--agent aco|llm|openai] — a Monte-Carlo sweep of the Phase-2 pursuit via simulate_pursuit, tallying apprentice-escaped / Master-caught / stalemate across N seeds. It reports BALANCED only if both escape and capture occur (the design goal: "hard, not unfair" — neither trivially winnable nor impossible).

  • --apex N — a sweep of the Phase-3 sympathetic-bond clash for all six bloodlines, faithfully mirroring Phase3Screen::resolve_round (bond contribution, the nine chaos events, ritual inversion, ending determination). It flags any lineage as ONE-SIDED if it can only ever win or only ever lose, proving that after the signature hooks every bloodline can both win and lose the clash — no dominant, no inert lineage.

Plain invocation (no flag) prints a single state_hash for a given --seed / --lineage / --turns, which is convenient for comparing two builds or two platforms for bit-identical behavior.

7.2 Why the split pays off

Because ms_core carries no raylib dependency, the entire ruleset — determinism, Phase-2 balance, and Phase-3 fairness — is verifiable in headless CI in milliseconds, with no window, no GPU, and no audio device. The presentation executable can then be treated as a thin, best-effort view over a simulation that is already proven correct.


Appendix — Key State Meters

For cross-reference, the meters the engine tracks and clamps (ranges and tiers from src/core/constants.hpp and game_state.cpp):

Meter Range Notes
ritual_clock 0–100 4 stages: Preparation <25, Alignment, Convergence ≥50, Possession ≥75.
suspicion 0–10 Phase 1 only; tiers safe / caution (≥3) / alert (≥6) / paranoid (≥9).
trace 0–100 Primary Master tracking vector; medium ≥21, high ≥51, critical ≥81.
echo_bleed 0–100 Whispers ≥21, intrusion ≥41, dominion ≥61, consumed ≥81.
parasite 0–100 Trace Parasite; aggressive ≥40, parasitic ≥70, else symbiotic.
bond 0–100 Sympathetic bond; Veyra starts at 30, all others at 15.

Day/night runs on kDaySegments = 12 (night = segments 6–11). All player-affecting changes to these meters pass through GameState's clamped mutators, and every one of them is folded into state_hash — so the determinism guarantee covers the full simulation, meters and flags alike.