"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:
- The Agent protocol — the
IAgentinterface 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. - Driving the Master with an LLM — the environment-variable reference, the
agent_enginebridge protocol, the asynchronous decision model, and the validation plus ACO-fallback guarantees that forbid cheating. - The ContentDB data format — the flat
key: valuerecord grammar underdata/, the---separators, and theRecord/ContentDBgetters. - The engine Action API — the
ActionTypetaxonomy and theapply/ journal contract underpinning determinism and replay.
All signatures below are copied verbatim from the headers. Cited files are relative to the repository root.
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.
// 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.
// 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 ofobs.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.
// 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.
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.
| 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.
// 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();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.
| 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 NoneIf no model is resident, pick_model() returns None and the bridge answers from its
own heuristic — the game never blocks on a model load.
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 0Observation 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.
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):
- Structured tool call — parse
act's arguments; amoverequires a valid0 ≤ index < len(legal). - Tag/JSON epilogue fallback — if the model narrated instead of calling the tool,
stream_processor.parsescrapes inline tags ([MOVE:N],[FORAGE],[REST],[SAY:...],[MOOD:x]), a trailing{"tool":..., "index":N}blob, or a bareindex N. - 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=1or 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.
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
Movedecision is only used if itstargetis still in the currentlegal_moves; if the world moved on, the ACO brain plays instead. narrationis cleared after use so a line is spoken only once.
The Master cannot cheat, at every layer:
-
LLMAgent(src/agent/llm_agent.cpp) is inert unlessMS_LLM_URLis 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 tofallback_.decide(obs):int idx = find_int(content, "\"index\"", -1); if (idx < 0 || idx >= (int)obs.legal_moves.size()) return fallback_.decide(obs);
-
BridgeAgentre-validates the returned index insidecall_bridgeand the cached decision against current legal moves before use (§2.4); an unavailable script, empty stdout, or invalid choice yields the ACO play. -
agent_enginenever raises across the seam:LMSClient.chat/.completecatch every exception and return empty content, andmaster_agent.decidealways 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.
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
- 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_dirwalks.mdand.txtfiles recursively, in sorted order.- A record is indexed by its
idfield (if present);by_typefilters on thetypefield.
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...
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. |
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;nullptrif unknown.by_type(type)— linear scan returning every record whosetypefield matches (e.g.by_type("spell")).resolve_resource_dir("data")— probes"",./,../…../../../../so the game findsdata/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 ---.
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."
// 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.
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
MasterActvocabulary (Move/Forage/Rest, §1.2). Phase 2 translates the chosenAgentActioninto the corresponding engineAction(e.g.Action::move(dir, /*master=*/true),Forage,Rest) before it is validated and applied — so the agent's decision flows through the sameis_legal/applygate as any other move.
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
RulesEngineRNG 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.
- Run the default (offline) Master: nothing to do —
MS_MASTER_AGENTunset ⇒ deterministic ACO. - Run a local model via the bridge: start LM Studio with a model loaded, then
set
MS_MASTER_AGENT=llm. Optionally pinMS_LLM_MODEL(must be resident) andMS_LLM_URL/MS_LLM_KEY. Force the heuristic withMS_AGENT_HEURISTIC=1. - Run a frontier / raw endpoint: set
MS_MASTER_AGENT=openaiandMS_LLM_URL(required —LLMAgentis inert otherwise), plusMS_LLM_MODEL/MS_LLM_KEY. - Add content: drop a
key: valuerecord underdata/<type>/….md, separated by---, with a uniqueid. Reload the game. - Reproduce a run: capture
(seed, lineage, journal);RulesEngine::replay(orms_headless --selftest) yields an identicalstate_hash.
The wood remembers every step. So does the journal.