Skip to content

Latest commit

 

History

History
696 lines (568 loc) · 28.1 KB

API.md

File metadata and controls

696 lines (568 loc) · 28.1 KB

The Master's Shadow — Integration API Reference

"He is bound by the same wood, the same hunger, the same rules. That is the only mercy the ritual grants you."

This document is the integration reference for embedders and modders. It covers four seams:

  1. The Agent protocol — the IAgent interface that lets any brain (deterministic or neural) drive Master Malacor during the Phase-2 Forest Pursuit, and the honest, bounded observation that keeps it restricted just like the player.
  2. Driving the Master with an LLM — the environment-variable reference, the agent_engine bridge protocol, the asynchronous decision model, and the validation plus ACO-fallback guarantees that forbid cheating.
  3. The ContentDB data format — the flat key: value record grammar under data/, the --- separators, and the Record / ContentDB getters.
  4. The engine Action API — the ActionType taxonomy and the apply / journal contract underpinning determinism and replay.

All signatures below are copied verbatim from the headers. Cited files are relative to the repository root.


1. The Agent Protocol

Header: src/agent/agent.hpp · src/ai/master_ai.hpp

During the Forest Pursuit, the Master is not scripted — he is a pluggable decision-maker. Every turn the engine hands the active agent a bounded observation and receives one action back. The agent may be the default deterministic ACO/pheromone brain, a raw OpenAI-compatible endpoint, or a local model behind the Python bridge. All of them implement the same interface.

1.1 IAgent

// Pluggable decision-maker for the Master. Default is the deterministic ACO brain;
// an LLM (local or frontier) can be plugged in via LLMAgent.
class IAgent {
public:
    virtual ~IAgent() = default;
    virtual AgentAction decide(const AgentObs& obs) = 0;
    virtual const char* name() const = 0;
    virtual void reset() {}
    virtual void observe(const Hex& player_hex, bool seen) {}
    virtual void decay() {}
    virtual MasterStrategy strategy() const { return MasterStrategy::Idle; }
    // Nyxari false trail: poison the Master's scent memory (default: no effect).
    virtual void plant_decoy(const Hex& hex, float amt, bool steal_fix) {}
};
Method Called Purpose
decide(obs) Each Master turn The only required decision hook: returns the chosen AgentAction.
name() Diagnostics / HUD Human-readable label (e.g. "ACO", "LLM", "CosySim/LLM", or a (->ACO) fallback tag).
reset() New pursuit Clears per-run memory (scent map, fix, strategy FSM).
observe(player_hex, seen) When the player acts / is spotted Deposits pheromone scent and updates the last-known fix. seen marks a fresh sighting.
decay() Passive tick Evaporates pheromone (the trail cools over time).
strategy() HUD / balance Reports the current FSM mode (see MasterStrategy below).
plant_decoy(hex, amt, steal_fix) Nyxari false trail [D] Lays a phantom scent spike and, if steal_fix, hijacks the Master's last-known fix toward the decoy so he chases the wrong way.

strategy() returns one of:

enum class MasterStrategy : uint8_t { Idle = 0, Opportunistic = 1, Bloodhound = 2, Architect = 3 };
  • Idle — no fix; wandering / recovering.
  • Opportunistic — a stale trail; probes toward the strongest remaining scent.
  • Bloodhound — hot trail (trace ≥ 50); direct pursuit.
  • Architect — mid trail (trace ≥ 30); interception, cutting toward the gate.

1.2 AgentAction and AgentObs

// The Master's Phase-2 action space (the same legal moves the engine would allow
// a unit): step to an adjacent hex, forage, or rest. The agent picks among these.
struct AgentAction {
    MasterAct act = MasterAct::Move;
    Hex target;
    std::string narration;  // optional in-character line the Master speaks (LLM)
};

// Everything an agent is allowed to see. Honest and bounded — this is what keeps
// a plugged-in LLM "restricted just like the player."
struct AgentObs {
    MasterView view;
    std::vector<Hex> legal_moves;  // walkable neighbours of the Master
};

The Master's Phase-2 action space is deliberately tiny:

enum class MasterAct : uint8_t { Move, Forage, Rest };
  • Move — step to AgentAction::target, which must be one of obs.legal_moves.
  • Forage — replenish food/energy in place.
  • Rest — recover energy/mana in place.

narration is optional flavour: if the agent supplies a line (LLM agents do), the Master speaks it once. Non-move actions ignore target.

1.3 MasterView — the honest, bounded observation

// What the Master brain observes each turn (a bounded, honest view — no cheating).
struct MasterView {
    const HexMap* map = nullptr;
    Hex master, gate;
    Hex player_last_known;
    bool has_fix = false;   // is the player currently in view / freshly tracked
    int master_food = 0, master_energy = 0, master_mana = 0;
    int player_trace = 0;
    int turn = 0;
};

This is the entire information channel the Master receives. He does not get the player's true hex — only player_last_known, the last position he had a fix on, plus has_fix telling him whether that fix is fresh. His own resources (master_food, master_energy, master_mana) and the player's player_trace (0–100, how loud a trail the apprentice is leaving) are visible; the player's inventory, meters, and plans are not. This asymmetry is what a Nyxari false trail exploits — poisoning player_last_known sends the hunter the wrong way.

1.4 How legal moves constrain any agent

The legal_moves vector is computed by the engine (walkable neighbours of the Master's hex) and is the only set of destinations any agent may return. Every agent path re-validates the chosen move against this list before it is applied, so an agent — LLM or otherwise — physically cannot teleport, phase through terrain, or move further than a player unit could. The validation lives at three layers:

Default ACO agent (src/agent/agent.cpp) guards its own brain's output:

AgentAction ACOMasterAgent::decide(const AgentObs& obs) {
    MasterDecision d = brain_.decide(obs.view);
    AgentAction a;
    a.act = d.act;
    a.target = d.target;
    // Guard: if the brain's Move target isn't a legal step, forage instead.
    if (a.act == MasterAct::Move) {
        bool legal = false;
        for (const Hex& m : obs.legal_moves)
            if (m == a.target) { legal = true; break; }
        if (!legal && !obs.legal_moves.empty()) a.target = obs.legal_moves.front();
        else if (!legal) a.act = MasterAct::Rest;
    }
    return a;
}

The LLM and bridge agents apply the identical legal_moves check (§2.4) and fall back to the ACO brain on any illegal or missing choice.

1.5 The concrete agent classes

Class name() Backend Config
ACOMasterAgent "ACO" Deterministic pheromone brain (MasterBrain) none — always available, offline, reproducible
LLMAgent "LLM" / "LLM(->ACO)" Raw OpenAI-compatible HTTP via curl MS_LLM_URL, MS_LLM_MODEL, MS_LLM_KEY
BridgeAgent "CosySim/LLM" / "CosySim(->ACO)" agent_engine/run.py → LM Studio, async env below + a loaded LM Studio model

All three own an ACOMasterAgent fallback_ and forward reset / observe / decay / strategy / plant_decoy to it, so scent memory and the strategy FSM stay coherent even when the neural brain is unreachable.

1.6 The factory

// Factory: "aco" (default), "llm"/"cosysim" (Python bridge), or "openai" (raw HTTP).
std::unique_ptr<IAgent> make_master_agent(const std::string& kind);
std::unique_ptr<IAgent> make_master_agent(const std::string& kind) {
    if (kind == "llm" || kind == "cosysim") return std::make_unique<BridgeAgent>();
    if (kind == "openai") return std::make_unique<LLMAgent>();
    return std::make_unique<ACOMasterAgent>();
}

Phase 2 selects the agent at pursuit start from MS_MASTER_AGENT (src/game/phase2.cpp):

// The Master is a pluggable agent. Default ACO (offline, deterministic);
// set MS_MASTER_AGENT=llm to drive him with a local/frontier model.
const char* kind = std::getenv("MS_MASTER_AGENT");
master_agent_ = make_master_agent(kind ? kind : "aco");
master_agent_->reset();

2. Driving the Master with an LLM

Files: src/agent/llm_agent.cpp · src/agent/bridge_agent.cpp · agent_engine/{config,lmstudio_client,master_agent,stream_processor,run}.py

There are two neural paths. MS_MASTER_AGENT=openai uses LLMAgent, a self-contained C++ client that shells out to curl against any OpenAI-compatible /chat/completions endpoint. MS_MASTER_AGENT=llm (alias cosysim) uses BridgeAgent, which invokes the standalone agent_engine Python package — the LM Studio / CosySim foundation — that never force-loads a model and prefers structured OpenAI tool-calling.

2.1 Environment-variable reference

Variable Read by Default Meaning
MS_MASTER_AGENT phase2.cpp aco Agent kind: aco (default), llm or cosysim (Python bridge), openai (raw C++ HTTP).
MS_LLM_URL LLMAgent (C++); config.base_url() (Python) (unset → LLMAgent stays disabled); Python defaults to http://127.0.0.1:1234/v1 Endpoint. LLMAgent posts to this exact URL; the bridge appends /chat/completions.
MS_LLM_MODEL LLMAgent; config.pick_model() C++: gpt-4o-mini; Python: first loaded model Model id. The bridge honours it only if that model is already loaded (or MS_LLM_FORCE=1).
MS_LLM_KEY LLMAgent; config.api_key() falls back to lmstudio-api-key.txt Bearer token. If unset, both paths read the first non-empty line of lmstudio-api-key.txt. Never log, journal, or serialize the key (MSSAVE/MSSNAP/console).
MS_LLM_TIMEOUT LMSClient 20 (seconds) HTTP timeout for the bridge's httpx client. (LLMAgent's curl uses a fixed --max-time 30.)
MS_LLM_MAXTOK master_agent.decide 220 Max tokens for the bridge's tool-call completion.
MS_AGENT_HEURISTIC master_agent.decide (unset) 1 forces the Python bridge to skip the model and use the deterministic bloodhound heuristic.
MS_LLM_FORCE config.pick_model (unset) 1 lets the bridge request MS_LLM_MODEL even if it is not resident (may trigger a load).

Key resolution (Python, agent_engine/config.py) and model selection are loaded-only by design:

def pick_model() -> str | None:
    """Choose a model to use WITHOUT triggering a load.

    Honors MS_LLM_MODEL only if that model is already loaded (or MS_LLM_FORCE=1).
    Otherwise uses the first loaded model, else None.
    """
    want = os.environ.get("MS_LLM_MODEL", "").strip()
    loaded = loaded_models()
    if want:
        if want in loaded or os.environ.get("MS_LLM_FORCE") == "1":
            return want
    return loaded[0] if loaded else None

If no model is resident, pick_model() returns None and the bridge answers from its own heuristic — the game never blocks on a model load.

2.2 The agent_engine bridge protocol

BridgeAgent locates the script by probing (preferring the standalone package, then the legacy shim):

agent_engine/run.py         ../agent_engine/run.py   ../../agent_engine/run.py   ../../../agent_engine/run.py
tools/master_agent.py       ../tools/master_agent.py ../../tools/master_agent.py ../../../tools/master_agent.py

Each turn it serialises the observation to a temp file and invokes:

python "<script>" "<obs.json>"

run.py reads the observation (path argument or stdin), defaults mana/trace, and prints only the action JSON on stdout (diagnostics go to stderr):

def main() -> int:
    if len(sys.argv) > 1 and os.path.exists(sys.argv[1]):
        obs = json.load(open(sys.argv[1]))
    else:
        obs = json.load(sys.stdin)
    obs.setdefault("mana", 0)
    obs.setdefault("trace", 0)
    print(json.dumps(master_agent.decide(obs)))  # ONLY the action on stdout
    return 0

Observation JSON (emitted by bridge_agent.cpp from MasterView + legal_moves):

{
  "master": [q, r],
  "player": [q, r],
  "gate":   [q, r],
  "food": 40, "energy": 55, "mana": 10, "trace": 62,
  "legal": [[q,r], [q,r], ...]
}

Action JSON (returned on stdout): one of

{"action": "move", "index": 3, "say": "You cannot outrun the wood, child."}
{"action": "forage", "say": "..."}
{"action": "rest"}

index is an index into the legal array. say is the optional in-character line, surfaced as AgentAction::narration.

2.3 /no_think + single-tool act(...)

The bridge does not free-form parse by default. It offers the model exactly one OpenAI tool, act, and asks it (with a trailing /no_think) to call it (agent_engine/master_agent.py):

ACT_TOOL = [{
    "type": "function",
    "function": {
        "name": "act",
        "description": "Choose Master Malacor's action for this turn and speak one short line.",
        "parameters": {
            "type": "object",
            "properties": {
                "action": {"type": "string", "enum": ["move", "forage", "rest"],
                           "description": "move to hunt, forage if starving, or rest"},
                "index": {"type": "integer",
                          "description": "the legal move index (required when action=move)"},
                "say": {"type": "string",
                        "description": "one short, cold, menacing line to the fleeing apprentice"},
            },
            "required": ["action"],
        },
    },
}]

The completion is non-streaming with tool calling (LMSClient.complete):

out = client.complete(messages, model=model, tools=ACT_TOOL, tool_choice="auto",
                      max_tokens=int(os.environ.get("MS_LLM_MAXTOK", "220")))

Decision precedence in decide(obs):

  1. Structured tool call — parse act's arguments; a move requires a valid 0 ≤ index < len(legal).
  2. Tag/JSON epilogue fallback — if the model narrated instead of calling the tool, stream_processor.parse scrapes inline tags ([MOVE:N], [FORAGE], [REST], [SAY:...], [MOOD:x]), a trailing {"tool":..., "index":N} blob, or a bare index N.
  3. Deterministic heuristic — starving? forage. Otherwise step to the legal move with the smallest hex distance to the apprentice. This is also used immediately when MS_AGENT_HEURISTIC=1 or when no move is legal or no model is loaded.

Every branch re-checks 0 ≤ index < n before returning, so an out-of-range index from the model is discarded rather than applied.

2.4 The asynchronous decision model

A slow local model must never freeze the game, so BridgeAgent::decide is pipelined: the ACO brain plays instantly every turn while a background thread asks the model for a (possibly slower) decision. When that decision lands it is re-validated against the current legal moves and used from then on.

AgentAction BridgeAgent::decide(const AgentObs& obs) {
    if (available_ && obs.legal_moves.size() > 0) {
        // 1) harvest a finished decision
        if (has_pending_ &&
            pending_.wait_for(std::chrono::milliseconds(0)) == std::future_status::ready) {
            auto r = pending_.get();
            has_pending_ = false;
            if (r.first) { llm_action_ = r.second; have_llm_ = true; }
        }
        // 2) launch a fresh decision for the current view (one in flight at a time)
        if (!has_pending_) {
            AgentObs snap = obs;
            std::string script = script_;
            pending_ = std::async(std::launch::async,
                                  [snap, script]() { return call_bridge(snap, script); });
            has_pending_ = true;
        }
        // 3) use the freshest LLM decision, re-validated against current legal moves
        if (have_llm_) {
            bool ok = (llm_action_.act != MasterAct::Move);
            if (!ok)
                for (const Hex& m : obs.legal_moves)
                    if (m == llm_action_.target) { ok = true; break; }
            if (ok) {
                AgentAction out = llm_action_;
                llm_action_.narration.clear();  // a line is spoken only once
                return out;
            }
        }
    }
    // no LLM decision yet (or slow model still thinking) — the ACO brain plays now
    return fallback_.decide(obs);
}

Notes:

  • At most one bridge call is in flight at a time.
  • A Move decision is only used if its target is still in the current legal_moves; if the world moved on, the ACO brain plays instead.
  • narration is cleared after use so a line is spoken only once.

2.5 Validation + ACO-fallback guarantees

The Master cannot cheat, at every layer:

  • LLMAgent (src/agent/llm_agent.cpp) is inert unless MS_LLM_URL is set (configured_ = !url_.empty()); it parses the final JSON object after any <think> reasoning, and any missing content, unparseable reply, or out-of-range index falls back to fallback_.decide(obs):

    int idx = find_int(content, "\"index\"", -1);
    if (idx < 0 || idx >= (int)obs.legal_moves.size()) return fallback_.decide(obs);
  • BridgeAgent re-validates the returned index inside call_bridge and the cached decision against current legal moves before use (§2.4); an unavailable script, empty stdout, or invalid choice yields the ACO play.

  • agent_engine never raises across the seam: LMSClient.chat / .complete catch every exception and return empty content, and master_agent.decide always returns a valid action dict (heuristic as the floor).

Because the fallback is the deterministic ACO brain, a run with the LLM detached still proceeds; and because only legal_moves are ever applied, plugging in a model changes how the Master hunts, never what he is allowed to do.


3. The ContentDB Data Format

Header: src/core/content_db.hpp · Impl: src/core/content_db.cpp

All authored content — six lineages, 15 spells, 8 enemies, 20 events, 9 chaos events, 8 endings, 16 items — lives under data/ as git-native, human-editable, moddable flat records. The parser is intentionally minimal (no YAML/JSON dependency).

data/
  spells/spells.md      endings/endings.md    items/items.md
  chaos/chaos.md        enemies/enemies.md    events/events.md
  lineages/lineages.md

3.1 Record grammar

  • One record per block; blocks are separated by a line containing exactly ---.
  • Each field is a single line of the form key: value (first : splits; the key and value are trimmed).
  • Blank lines and lines beginning with # are ignored — so a file can carry comment banners freely.
  • A line with no : is skipped. Fields with an empty key are skipped.
  • load_dir walks .md and .txt files recursively, in sorted order.
  • A record is indexed by its id field (if present); by_type filters on the type field.

Example (data/spells/spells.md):

# The Master's Shadow — Spellbook
# ContentDB (strict flat): key: value per line, records split by ---

id: ward
type: spell
name: Ward
color: White
tier: 1
mana: 1
skill: Willpower
effect: shield 8 for one turn
description: A ring of pale sigils flares between you and the dark, and the dark, for now, obeys.
---
id: mend-flesh
type: spell
name: Mend Flesh
color: White
tier: 1
mana: 2
skill: Alchemy
effect: heal 6-10
description: The wound knits shut beneath your hand...

3.2 Record — fields and typed getters

struct Record {
    std::string source_file;
    std::map<std::string, std::string> fields;

    bool has(const std::string& key) const { return fields.count(key) != 0; }
    std::string get(const std::string& key, const std::string& def = "") const;
    int geti(const std::string& key, int def = 0) const;
    double getf(const std::string& key, double def = 0.0) const;
    bool getb(const std::string& key, bool def = false) const;
    std::vector<std::string> getlist(const std::string& key) const;  // comma-separated
};
Getter Returns Notes
has(key) bool Field present?
get(key, def) raw string Default when absent.
geti(key, def) int std::stoi; returns def on parse failure.
getf(key, def) double std::stod; returns def on parse failure.
getb(key, def) bool true for true / yes / 1 (case-insensitive).
getlist(key) vector<string> Comma-separated; optional surrounding [ ] stripped; items trimmed; empties dropped.

3.3 ContentDB — loading and lookup

class ContentDB {
public:
    bool load_dir(const std::string& path);   // loads *.md / *.txt recursively
    bool load_file(const std::string& path);
    void clear();

    size_t size() const { return records_.size(); }
    const std::vector<Record>& all() const { return records_; }
    const Record* by_id(const std::string& id) const;
    std::vector<const Record*> by_type(const std::string& type) const;

private:
    std::vector<Record> records_;
    std::map<std::string, int> id_index_;
};

// Search common relative locations for a resource directory (so the game works
// whether launched from repo root, build/bin, or an install layout).
std::string resolve_resource_dir(const std::string& name);
  • by_id(id) — O(1) lookup via the id index; nullptr if unknown.
  • by_type(type) — linear scan returning every record whose type field matches (e.g. by_type("spell")).
  • resolve_resource_dir("data") — probes "", ./, ../ … ../../../../ so the game finds data/ whether launched from the repo root, build/bin, or an install layout.

Modding: add a new record (or a whole new .md file) under data/, give it a unique id and the right type, and it is loaded on next launch — no recompile. Keep one key: value per line and separate records with ---.


4. The Engine Action API

Header: src/core/action.hpp · Engine: src/core/rules_engine.hpp / .cpp

Every legal move — player or Master — is a single Action. The Master is bound to the same taxonomy; that is the mechanical root of "restricted just like the player."

4.1 ActionType

// Every legal move a player OR the Master can submit. The Master is bound to the
// same taxonomy — that is what keeps it "restricted just like the player."
// The set grows per phase; generic int/string params keep the journal compact
// and serialisable. Enum values are stable (save/replay format).
enum class ActionType : uint16_t {
    Noop = 0,
    EndTurn = 1,        // advance turn, day segment, passive ticks
    Move = 2,           // a = hex direction 0..5
    SkillCheck = 3,     // a = modifier, b = dc, s = result-flag key (optional)
    CastSpell = 4,      // a = MagicColor, b = mana cost, c = trace delta, d = clock delta
    Forage = 5,         // b = dc
    Rest = 6,           // recover energy/mana
    PerformChore = 7,   // b = dc  (Phase 1)
    AttemptPuzzle = 8,  // a = skill modifier, b = dc, s = puzzle id
    CounterRitual = 9,  // b = clock reduction on success, a = alchemy modifier
    AdvanceRitual = 10, // a = amount (Master research)
    LearnSpell = 11,    // a = MagicColor, b = dc, s = spell id
    SetFlag = 12,       // s = key, a = value (scripted/debug)
    AdjustSuspicion = 13, // a = delta (Phase 1 detection risk etc.)
    GrantItem = 14,     // s = item id, a = count (default 1) -> flag "item_<id>" += a
    GrantSpell = 15,    // s = spell id (unconditional unlock) -> flag "spell_<id>" = 1
};

Enum values are stable — the save/replay format depends on them; never renumber.

4.2 Action

struct Action {
    ActionType type = ActionType::Noop;
    int a = 0, b = 0, c = 0, d = 0;
    std::string s;
    bool by_master = false;

    static Action end_turn(bool master = false) {
        Action x; x.type = ActionType::EndTurn; x.by_master = master; return x;
    }
    static Action move(int dir, bool master = false) {
        Action x; x.type = ActionType::Move; x.a = dir; x.by_master = master; return x;
    }
};

The four generic ints (a–d) and one string (s) carry per-type parameters (see the inline comments in ActionType), keeping the journal compact and serialisable. by_master distinguishes the two actors sharing the taxonomy. The static factories cover the two most common actions.

Bridge note. Phase-2 agents speak the smaller MasterAct vocabulary (Move / Forage / Rest, §1.2). Phase 2 translates the chosen AgentAction into the corresponding engine Action (e.g. Action::move(dir, /*master=*/true), Forage, Rest) before it is validated and applied — so the agent's decision flows through the same is_legal / apply gate as any other move.

4.3 The apply / journal contract

class RulesEngine {
public:
    struct ApplyResult {
        bool ok = false;
        bool had_check = false;
        CheckResult check;
        std::string message;
    };

    RulesEngine(uint64_t seed, Lineage lineage);
    explicit RulesEngine(const GameState& gs);  // resume from loaded state (fresh RNG from seed)

    const GameState& state() const { return state_; }
    // Setup, phase entry, and currently some screen-owned gameplay.
    // Prefer apply() for journaled changes; see docs/MUTABLE_STATE_INVENTORY.md.
    GameState& mutable_state() { return state_; }
    Rng& rng() { return rng_; }
    EventBus& events() { return bus_; }
    const std::vector<Action>& journal() const { return journal_; }

    bool is_legal(const Action& a) const;
    ApplyResult apply(const Action& a);

    // Reconstruct a state deterministically from a recorded journal.
    static GameState replay(uint64_t seed, Lineage lineage, const std::vector<Action>& journal);
    // ...
};

Every action passes through one gate. apply rejects illegal actions, dispatches the legal ones, and appends the action to the 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);
    return res;
}

The determinism guarantee. The engine owns one authoritative GameState and a single RNG stream seeded from the game seed. Because every mutation flows through apply and is journalled, the triple (seed, lineage, journal) reproduces an identical state — the same guarantee that makes the Master auditable and forbids cheating. replay rebuilds that state by re-applying the journal on a fresh engine:

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();
}

GameState::state_hash() produces an order-fixed 64-bit fingerprint over all fields; ms_headless prints it so that (seed, lineage, turns) can be verified to yield an identical state_hash across runs and machines.

One stream, one side-stream. The RulesEngine RNG drives all gameplay resolution. The faction war / encounter gating uses a separate side-stream (war_rng_), so crowd-simulated warband contests never perturb the main stream — the replay hash for (seed, lineage, actions) stays identical regardless of war activity.


Appendix — Integration Checklist

  • Run the default (offline) Master: nothing to do — MS_MASTER_AGENT unset ⇒ deterministic ACO.
  • Run a local model via the bridge: start LM Studio with a model loaded, then set MS_MASTER_AGENT=llm. Optionally pin MS_LLM_MODEL (must be resident) and MS_LLM_URL / MS_LLM_KEY. Force the heuristic with MS_AGENT_HEURISTIC=1.
  • Run a frontier / raw endpoint: set MS_MASTER_AGENT=openai and MS_LLM_URL (required — LLMAgent is inert otherwise), plus MS_LLM_MODEL / MS_LLM_KEY.
  • Add content: drop a key: value record under data/<type>/…​.md, separated by ---, with a unique id. Reload the game.
  • Reproduce a run: capture (seed, lineage, journal); RulesEngine::replay (or ms_headless --selftest) yields an identical state_hash.

The wood remembers every step. So does the journal.