F8 toggles edit mode. Tools: brush/line/rect/ellipse/flood/gradient/picker; ink meanings write LOOK/FLOOR/WALL/WATER/DEPTH across the four screens; V overlays priority/control/hotspot; hotspot rects, prop placement, spawn, and a :colon command line (save/name/enter/music/weather/exit/room/hotmsg). Every editor action is a bus Command (paint_op, undo_op, clear_ops, set_spawn/exit/music/weather/room_name/enter_text, upsert/delete hotspot+prop) so MCP clients wield identical powers. PicOp::Brush added; docs/EDITOR.md. Verified: 5 suites green, playthrough events byte-identical to pre-editor build, GUI boots. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
167 lines
6.7 KiB
Markdown
167 lines
6.7 KiB
Markdown
# Driving MRPCI programmatically
|
|
|
|
The engine core (`mrpci-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 mrpci-core # builds target/debug/mrpci-headless
|
|
mrpci-headless --help
|
|
```
|
|
|
|
`--game DIR` points any mode at a game folder. Useful boot flags:
|
|
`--seed N` (deterministic RNG), `--room N`, `--ticks N`,
|
|
`--render-room out.png` (annotated: room chip, score, dialogue/transcript
|
|
text stamped with the built-in font; add `--raw` for the bare scene),
|
|
`--screens dir/`.
|
|
|
|
NPCs and props with `dialogue` may set `portrait` (a sprite name) — the
|
|
talker face drawn beside the dialogue window in the GUI and in annotated
|
|
frames. `--script FILE --render-room OUT.png` renders the frame a replay
|
|
ends on (screenshot-after-scenario).
|
|
|
|
RoomDoc extras beyond the basics: procedural paint ops `fbm_fill`
|
|
(ramp/scale/octaves/seed), `voronoi_fill` (cell/colors/edge_color/edge/seed),
|
|
`scatter` (colors/density/seed) — all deterministic by seed — and a
|
|
`weather` field (0 none, 1 rain, 2 snow, 3 embers) whose particles run on a
|
|
separate RNG stream (visual-only; event replays unaffected).
|
|
|
|
## 1. JSONL stdio (the substrate)
|
|
|
|
One JSON command per line on stdin, events stream back on stdout.
|
|
Lines starting with `#` are comments (script files too).
|
|
|
|
```jsonl
|
|
{"cmd":"verb_at","verb":"look","x":100,"y":120}
|
|
{"cmd":"verb_at","verb":"do","x":268,"y":112}
|
|
{"cmd":"use_item_at","item":"keycard","x":286,"y":138}
|
|
{"cmd":"parse","text":"use keycard on locker"}
|
|
{"cmd":"move_to","x":150,"y":150}
|
|
{"cmd":"walk_to","x":316,"y":150}
|
|
{"cmd":"tick","n":30}
|
|
{"cmd":"choose","n":1}
|
|
{"cmd":"end_dialogue"}
|
|
{"cmd":"query"}
|
|
{"cmd":"new_game"}
|
|
{"cmd":"goto_room","n":2}
|
|
{"cmd":"set_flag","name":"locker_open","value":true}
|
|
{"cmd":"set_var","name":"drip_armed","value":0}
|
|
{"cmd":"set_seed","seed":41}
|
|
{"cmd":"save_game","slot":"street"}
|
|
{"cmd":"restore_game","slot":"street"}
|
|
{"cmd":"paint_op","op":{"Rect":{"x":10,"y":120,"w":30,"h":20,"ink":{"color":42,"pri":"Band","ctl":{"Set":0},"hot":null}}}}
|
|
{"cmd":"undo_op"}
|
|
{"cmd":"clear_ops"}
|
|
{"cmd":"set_spawn","x":60,"y":150}
|
|
{"cmd":"set_exit","dir":1,"target":2}
|
|
{"cmd":"set_music","mood":6}
|
|
{"cmd":"set_weather","kind":1}
|
|
{"cmd":"set_room_name","name":"Neon Row"}
|
|
{"cmd":"set_enter_text","text":"Rain again."}
|
|
{"cmd":"upsert_hotspot","hotspot":{"name":"door","rect":[206,104,34,12],"exit_to":1}}
|
|
{"cmd":"delete_hotspot","name":"door"}
|
|
{"cmd":"upsert_prop","prop":{"name":"crate-2","sprite":"crate","x":100,"y":140,"solid":true}}
|
|
{"cmd":"delete_prop","name":"crate-2"}
|
|
{"cmd":"upsert_room","n":7,"doc":{"spawn":[160,170]},"persist":true}
|
|
{"cmd":"upsert_script","file":"room7.rhai","source":"fn on_enter() { say(\"hi\"); }"}
|
|
{"cmd":"save_room"}
|
|
{"cmd":"load_game","dir":"games/other"}
|
|
{"cmd":"reload_assets"}
|
|
```
|
|
|
|
Notes:
|
|
- The engine only advances when told: `tick` runs fixed 1/30s cycles
|
|
(movement, timers, triggers, NPC wander). `walk_to` ticks until arrival.
|
|
- `verb_at` verbs: `walk`, `look`, `do`, `talk`. Distant do/talk/take
|
|
targets are walked to first (the pending verb fires on arrival).
|
|
- `parse` accepts the same things a player types: `look at sign`,
|
|
`pick up the keycard`, `use keycard on locker`, `talk to sergeant`,
|
|
`inventory`, `save`, `score`.
|
|
- `upsert_room` validates before committing (walkable spawn, exits and door
|
|
hotspots must point at rooms that exist) and rejects bad lore with an
|
|
`error` event.
|
|
- Events: `transcript`, `room_changed`, `inventory_changed`, `dialogue_open`
|
|
/ `dialogue_closed`, `score_changed`, `died` (checkpoint rewind follows),
|
|
`won`, `audio` (cue name), `music` (mood), `ego_moved`, `state`,
|
|
`saved_game` / `restored`, `room_upserted`, `room_saved`, `game_loaded`,
|
|
`error`.
|
|
|
|
### Script replay (deterministic)
|
|
|
|
```sh
|
|
mrpci-headless --game games/neon-precinct --script play.jsonl > events.log
|
|
```
|
|
|
|
Fixed cycles + seeded RNG + tick-based timers ⇒ the same script produces
|
|
**byte-identical** events on any machine, any speed. That's the golden-test
|
|
workflow for CI — and the reason none of the classic Sierra speed bugs can
|
|
exist here.
|
|
|
|
## 2. HTTP
|
|
|
|
```sh
|
|
mrpci-headless --game games/neon-precinct --serve 8093
|
|
```
|
|
|
|
```
|
|
POST /command one Command JSON → JSON array of Events
|
|
GET /state StateSnapshot
|
|
GET /frame.png current frame, headless-rendered (cycles applied)
|
|
GET /screen/priority.png depth bands, visualized
|
|
GET /screen/control.png walls red, water blue
|
|
GET /screen/hotspot.png hotspot id map
|
|
GET /events?since=N cursor-polled event log
|
|
POST /rooms/{n}[?persist] body = RoomDoc JSON (the lore drop)
|
|
```
|
|
|
|
## 3. MCP (Claude as player, tester, co-author)
|
|
|
|
```sh
|
|
claude mcp add mrpci -- /path/to/mrpci-headless --mcp --game /path/to/games/neon-precinct
|
|
```
|
|
|
|
Tools: `mrpci_verb`, `mrpci_use_item`, `mrpci_parse`, `mrpci_walk_to`,
|
|
`mrpci_choose`, `mrpci_tick`, `mrpci_state`, `mrpci_render_frame`,
|
|
`mrpci_render_screen`, `mrpci_save`, `mrpci_restore`, `mrpci_new_game`,
|
|
`mrpci_load_game`, `mrpci_upsert_room`, `mrpci_upsert_script`,
|
|
`mrpci_save_room`, `mrpci_command` (escape hatch).
|
|
|
|
`mrpci_render_frame` / `mrpci_render_screen` return real PNGs, so a model
|
|
can look at the frame — or the invisible screens — it just authored.
|
|
|
|
## The rhai scripting surface
|
|
|
|
Hooks a room script (or `main.rhai`, merged underneath) may define:
|
|
|
|
```rhai
|
|
fn on_enter() // room became live (also after death-rewind!)
|
|
fn on_exit() // leaving the room
|
|
fn on_verb(verb, noun) // FIRST refusal on every verb; return true = handled
|
|
// verb is "look"/"do"/"talk"/"take"/"use:<item>"/...
|
|
fn on_trigger(name) // ego stepped into a trigger hotspot
|
|
fn on_tick() // every cycle (keep it light)
|
|
fn any_name() // timer target for after(ticks, "any_name")
|
|
```
|
|
|
|
API: `say(t)`, `say_by(who,t)`, `flag(n)`, `set_flag(n,v)`, `val(n)`,
|
|
`set_val(n,v)`, `has(item)`, `give(item)`, `remove_item(item)`, `room()`,
|
|
`ego_x()`, `ego_y()`, `goto_room(n)`, `place_ego(x,y)`, `walk_ego(x,y)`,
|
|
`freeze_ego(b)`, `npc_walk(name,x,y)`, `npc_place(name,x,y)`,
|
|
`npc_freeze(name,b)`, `play(cue)`, `music(mood)`, `points(n)`, `win()`,
|
|
`die(text)`, `after(ticks, "fn")`, `pal_cycle(i, on)`, `rand(n)`.
|
|
|
|
Reads see a snapshot; writes queue as effects applied after the hook
|
|
returns. A script can print nonsense but cannot corrupt the sim, and a
|
|
runaway loop is killed at 50k ops. `rand` is the engine's seeded xorshift —
|
|
scripts stay replay-deterministic.
|