New authoring surface (all serde-defaulted; old games load unchanged): - RoomDoc: name + enter_text (announced on entry), exit_flags/exit_blocked (per-direction flag gates with authored refusal lines, latched per attempt) - ObjDef: points (once per room+name), consumes, kills, visible_flag, hidden_by_flag; DlgChoice: points (latched on sets_flag flip), kills (goto node's says = death text) - GameManifest: max_score enables scoring; GUI shows the classic 'Score: X of Y' in the menu bar; score_changed + died on the bus - Death restarts the day, keeping the cause at the top of the fresh transcript Reviewed by an adversarial multi-agent pass; confirmed findings fixed: corner push-back no longer slingshots through a perpendicular open exit (and checks the landing pixel), blocked messages latch per attempt, kill choices pay no points/flags, new_game re-zeroes the score on the bus, AI-lane prompts respect object visibility. Deliberate behavior change: drop/consumes now emit inventory_changed (was a silent client desync). Built for MORPQUEST (games/… ssh://…/monster/mrpquest.git), which uses all of it: 250-point budget, four gated exits, two deaths, vanishing Beckies. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
181 lines
7.4 KiB
Markdown
181 lines
7.4 KiB
Markdown
# Driving MRPGI programmatically
|
|
|
|
The engine core (`mrpgi-core`) is fully headless: a `GameState` driven by
|
|
JSON `Command`s in and `Event`s out, over your choice of surface. The GUI is
|
|
just one more consumer of the same bus.
|
|
|
|
```
|
|
GUI (macroquad) ─┐
|
|
CLI / stdio ─────┤
|
|
HTTP ────────────┼──▶ Command ▶ GameState ▶ Event
|
|
MCP (Claude) ────┤
|
|
Python ──────────┘
|
|
```
|
|
|
|
Everything below uses one binary:
|
|
|
|
```sh
|
|
cargo build -p mrpgi-core # builds target/debug/mrpgi-headless
|
|
mrpgi-headless --help
|
|
```
|
|
|
|
`--game DIR` points any mode at a game folder (default: the current dir).
|
|
|
|
## Game = a folder
|
|
|
|
```
|
|
games/mygame/
|
|
game.json # manifest: name, start_room, intro_text, verbs, flags
|
|
rooms/room0.json # RoomDoc: strokes, spawn, exits [N,E,S,W], objects, music
|
|
sprites/*.png # auto-quantized to EGA; filename = sprite name
|
|
sfx/door.ogg # optional: overrides the built-in chiptune per cue
|
|
music/calm.ogg # optional: overrides a music mood (by name or index 1-5)
|
|
```
|
|
|
|
Every manifest field defaults, so a bare `rooms/` folder is already a valid
|
|
game. Write starter content with `--sample` (3-room robot party) or `--kit`
|
|
(fantasy world + object archetype catalog).
|
|
|
|
## 1. JSONL stdio (the substrate)
|
|
|
|
```sh
|
|
mrpgi-headless --game games/mygame
|
|
```
|
|
|
|
One JSON command per line on stdin, events stream back on stdout:
|
|
|
|
```jsonl
|
|
{"cmd":"parse","text":"take key"}
|
|
{"cmd":"walk_to","x":150,"y":90}
|
|
{"cmd":"tick","n":20}
|
|
{"cmd":"choose","n":1}
|
|
{"cmd":"query"}
|
|
{"cmd":"new_game"}
|
|
{"cmd":"goto_room","n":2}
|
|
{"cmd":"set_flag","name":"power_on","value":true}
|
|
{"cmd":"paint_stroke","stroke":{"Rect":{"x":0,"y":0,"w":160,"h":16,"color":0,"meaning":"Wall"}}}
|
|
{"cmd":"upsert_object","obj":{"name":"gem","sprite":"potion","x":90,"y":120,"look":"Shiny.","takeable":true}}
|
|
{"cmd":"upsert_room","n":7,"doc":{"strokes":[],"spawn":[80,140]},"persist":true}
|
|
{"cmd":"save_room"}
|
|
{"cmd":"load_game","dir":"games/other"}
|
|
```
|
|
|
|
Notes:
|
|
- The engine only advances when told: `tick` runs fixed 1/20s cycles
|
|
(movement, room transitions, AI results). `walk_to` ticks until arrival.
|
|
- `upsert_room` validates before committing (walkable spawn, exits must point
|
|
at rooms that exist) and rejects bad lore with an `error` event.
|
|
- Events look like `{"event":"transcript","line":"You take the key."}`,
|
|
`{"event":"room_changed","room":1,"music":4}`, `{"event":"won"}`,
|
|
`{"event":"audio","cue":"pickup"}`, `{"event":"state","state":{...}}`.
|
|
|
|
### Script replay (deterministic)
|
|
|
|
```sh
|
|
mrpgi-headless --script play.jsonl --game games/mygame > events.log
|
|
```
|
|
|
|
Replays a command log through the fixed-step engine. The AI lane defaults to
|
|
**off** under `--script`, so the same script always produces byte-identical
|
|
events — that's the golden-test workflow for CI.
|
|
|
|
### Render a room to PNG (no window)
|
|
|
|
```sh
|
|
mrpgi-headless --render-room games/mygame/rooms/room0.json out.png --game games/mygame
|
|
```
|
|
|
|
## 2. HTTP
|
|
|
|
```sh
|
|
mrpgi-headless --serve 127.0.0.1:7777 --game games/mygame
|
|
```
|
|
|
|
| Route | Does |
|
|
|---|---|
|
|
| `POST /command` | body = one Command JSON → JSON array of resulting Events |
|
|
| `GET /state` | full snapshot: room, ego, inventory, flags, objects, exits |
|
|
| `GET /frame.png` | the current frame, headless-rendered |
|
|
| `GET /events?since=N` | event log from cursor N → `{"next":M,"events":[...]}` |
|
|
| `POST /rooms/{n}` | body = RoomDoc JSON (add `?persist` to write to disk) |
|
|
|
|
Python needs no client library:
|
|
|
|
```python
|
|
import requests, json
|
|
E = "http://127.0.0.1:7777"
|
|
requests.post(f"{E}/command", data=json.dumps({"cmd": "parse", "text": "look"}))
|
|
state = requests.get(f"{E}/state").json()
|
|
open("frame.png", "wb").write(requests.get(f"{E}/frame.png").content)
|
|
```
|
|
|
|
## 3. MCP (Claude authors & plays)
|
|
|
|
```sh
|
|
claude mcp add mrpgi -- /path/to/target/debug/mrpgi-headless --mcp --game games/mygame
|
|
```
|
|
|
|
Tools: `mrpgi_parse`, `mrpgi_walk_to`, `mrpgi_choose`, `mrpgi_tick`,
|
|
`mrpgi_state`, `mrpgi_render_frame` (returns a real PNG image — the model
|
|
*sees* the frame it authored), `mrpgi_new_game`, `mrpgi_load_game`,
|
|
`mrpgi_upsert_room`, `mrpgi_upsert_object`, `mrpgi_paint_stroke`,
|
|
`mrpgi_save_room`, and `mrpgi_command` (raw bus escape hatch).
|
|
|
|
The loop that makes a whole game from prompts: upsert rooms/objects as JSON →
|
|
`mrpgi_render_frame` to look at them → play the game with `mrpgi_parse` /
|
|
`mrpgi_walk_to` to test the puzzle chain → `mrpgi_save_room` to commit.
|
|
|
|
## Authoring JSON reference
|
|
|
|
**RoomDoc** — `{"strokes":[...], "spawn":[x,y], "exits":[N,E,S,W], "objects":[...], "music":0-5}`
|
|
(exit entries are room numbers or `null`; music: 0 off, 1 calm, 2 eerie, 3 tense, 4 jolly, 5 spooky)
|
|
plus optional `name` (shown on entry and in `look`), `enter_text` (entry prose),
|
|
`exit_flags` (`["","side-door-open","",""]` — that direction refuses until the
|
|
flag is true) and `exit_blocked` (what refusing says; "" = a stock line).
|
|
|
|
**Stroke** (paint ops; `meaning` is `"None" | "Floor" | "Wall" | "Water" | "Trigger"`, colors are EGA 0-15):
|
|
|
|
```json
|
|
{"Rect": {"x":0,"y":0,"w":160,"h":16,"color":0,"meaning":"Wall"}}
|
|
{"Ellipse": {"x":10,"y":40,"w":30,"h":20,"color":2,"meaning":"None"}}
|
|
{"Fill": {"x":80,"y":100,"color":7,"meaning":"Floor"}}
|
|
{"Line": {"pts":[[0,80],[159,80]],"color":15}}
|
|
{"Brush": {"pts":[[50,60],[52,61]],"color":4,"meaning":"None","size":2}}
|
|
{"Image": {"name":"tree","x":60,"y":80,"meaning":"None"}}
|
|
```
|
|
|
|
**ObjDef** — `name, sprite, x, y` plus optional `look`, `takeable`, `synonyms`
|
|
(space-separated), `use_text`, `needs` (item that unlocks it), `wins`,
|
|
`requires_flag` / `sets_flag` (puzzle gating), `dialogue` (nodes of
|
|
`{"says":"...","choices":[{"text":"...","goto":1,"requires_flag":"","sets_flag":""}]}`,
|
|
`goto:-1` ends). Sierra extras, all optional: `points` (awarded once per room —
|
|
on take for takeables, on first successful use otherwise), `consumes` (a
|
|
successful use removes the `needs` item from inventory), `kills` (a successful
|
|
use is death: the day restarts, `use_text` is the death text), `visible_flag`
|
|
(object exists only while the flag is true) and `hidden_by_flag` (object
|
|
disappears once the flag is true). Dialogue choices also take `points`
|
|
(awarded when their `sets_flag` first flips true — the flag is the latch) and
|
|
`kills` (death; the `goto` node's `says` is printed as the death text).
|
|
Precedence: `kills` preempts `points`/`wins`/`sets_flag` — a death pays
|
|
nothing and wins nothing. The points budget is the author's contract: nothing
|
|
stops the sum of `points` exceeding `max_score`; audit your own game.
|
|
Note (v2 behavior change): `drop` and `consumes` now emit `inventory_changed`
|
|
— previously drop silently desynced client inventory displays.
|
|
|
|
**game.json** — `{"name":"...", "start_room":0, "intro_text":"...", "verbs":[{"name":"take","words":["yoink"]}], "flags":{"met_wizard":false}, "max_score":250}`
|
|
(manifest verbs merge extra synonyms into the built-in table; the AI parser's
|
|
whitelist is generated from the same table so they can never drift. `max_score`
|
|
turns scoring on: the GUI shows the classic `Score: X of Y` and `score_changed`
|
|
events fire; 0 or absent = scoring off. Death emits `died` followed by the
|
|
usual `new_game` events.)
|
|
|
|
## The AI parser lane
|
|
|
|
Free text the deterministic parser doesn't understand goes to a small LLM
|
|
that may only translate it into an *already-legal* command, which is then
|
|
re-run through the real logic — the AI can never invent state.
|
|
|
|
```
|
|
MRPGI_AI=ollama|openrouter|off MRPGI_AI_MODEL=... MRPGI_AI_URL=...
|
|
```
|