MRPGI/mrpgi-core/src/bin/mrpgi-headless/main.rs
type-two 96b45561d0 Carve the engine into a headless core behind a command/event bus
The handover plan (docs/HANDOVER.md), P0-P6, in one pass. One insight
powers it: CLI/HTTP/MCP/Python is one feature, not four — a headless
core driven by Commands in, Events out, with every surface a thin
adapter.

- mrpgi-core: the whole sim as a library with zero macroquad (the
  workspace boundary is the guardrail). GameState::apply/tick, fixed
  1/20s cycles, deterministic replays, headless RGBA/PNG rendering.
- mrpgi-headless: JSONL stdio REPL, --script replay (AI lane off =
  byte-identical), --render-room PNG, --serve HTTP (state/command/
  events cursor/frame.png), --mcp JSON-RPC stdio server whose
  render_frame returns a real PNG so a model sees its own moves.
- Games are folders: game.json manifest (all fields default — legacy
  dirs still load), data-driven verb table feeding both the parser and
  the AI whitelist, flags with requires_flag/sets_flag gating on
  objects and dialogue choices, live UpsertRoom lore ingestion with
  validation.
- mrpgi (GUI): now one consumer of the bus; editor commits through the
  same commands MCP uses; audio became core events with per-game
  sfx/ music/ file overrides beating the chiptune synth.
- Tests: parser goldens + a full win-path playthrough asserting
  byte-identical transcripts across runs.

Deleted: the old fused src/ (root is now a virtual workspace), the
dead demo Scene/Assets path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 09:18:33 +10:00

159 lines
5.9 KiB
Rust

//! mrpgi-headless — every non-GUI way to drive the engine, in one binary.
//!
//! mrpgi-headless [--game DIR] JSONL REPL (commands in, events out)
//! mrpgi-headless --script FILE [--game DIR] replay a command log, print events
//! mrpgi-headless --render-room IN.json OUT.png [--game DIR]
//! mrpgi-headless --serve [ADDR] [--game DIR] HTTP surface (default 127.0.0.1:7777)
//! mrpgi-headless --mcp [--game DIR] MCP server on stdio (for Claude etc.)
//! mrpgi-headless --sample | --kit [--game DIR] write starter content, then exit
//!
//! JSONL wire format, one JSON object per line:
//! in: {"cmd":"parse","text":"take key"}
//! out: {"event":"transcript","line":"You take the key."}
mod http;
mod mcp;
use mrpgi_core::ai::AiConfig;
use mrpgi_core::state::{Command, Event};
use mrpgi_core::{GameState, World};
use std::io::{BufRead, Write};
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let get_opt = |flag: &str| -> Option<String> {
args.iter().position(|a| a == flag).and_then(|i| args.get(i + 1)).cloned()
};
let game_dir = get_opt("--game").unwrap_or_else(|| ".".into());
if args.iter().any(|a| a == "--help" || a == "-h") {
print!("{}", HELP);
return;
}
if args.iter().any(|a| a == "--sample") {
mrpgi_core::kit::write_sample_world(&game_dir);
eprintln!("sample game written to {}", game_dir);
return;
}
if args.iter().any(|a| a == "--kit") {
mrpgi_core::kit::write_kit(&game_dir);
eprintln!("fantasy kit written to {}", game_dir);
return;
}
if let Some(pos) = args.iter().position(|a| a == "--render-room") {
let input = args.get(pos + 1).expect("--render-room needs IN.json OUT.png");
let output = args.get(pos + 2).expect("--render-room needs IN.json OUT.png");
render_room(&game_dir, input, output);
return;
}
// Scripted replays default the AI lane off so they're deterministic;
// export MRPGI_AI to opt back in.
let scripted = args.iter().any(|a| a == "--script");
let ai = if scripted && std::env::var("MRPGI_AI").is_err() { AiConfig::off() } else { AiConfig::from_env() };
let mut gs = GameState::new(World::load_game(&game_dir), ai);
let boot = gs.apply(Command::NewGame { room: None });
if args.iter().any(|a| a == "--mcp") {
mcp::serve(gs);
return;
}
if let Some(pos) = args.iter().position(|a| a == "--serve") {
let addr = args
.get(pos + 1)
.filter(|a| !a.starts_with("--"))
.cloned()
.unwrap_or_else(|| "127.0.0.1:7777".into());
http::serve(gs, boot, &addr);
return;
}
let stdout = std::io::stdout();
let mut out = stdout.lock();
emit(&mut out, &boot);
if let Some(path) = get_opt("--script") {
let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("can't read {}: {}", path, e));
for line in text.lines() {
run_line(&mut gs, line, &mut out);
}
return;
}
// The JSONL REPL: read commands until EOF.
let stdin = std::io::stdin();
for line in stdin.lock().lines() {
let Ok(line) = line else { break };
run_line(&mut gs, &line, &mut out);
}
}
fn run_line(gs: &mut GameState, line: &str, out: &mut impl Write) {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
return;
}
match serde_json::from_str::<Command>(line) {
Ok(cmd) => emit(out, &gs.apply(cmd)),
Err(e) => emit(out, &[Event::Error { message: format!("bad command: {}", e) }]),
}
}
fn emit(out: &mut impl Write, events: &[Event]) {
for e in events {
if let Ok(j) = serde_json::to_string(e) {
let _ = writeln!(out, "{}", j);
}
}
let _ = out.flush();
}
/// Bake a single RoomDoc to a PNG — no window, no game state. This is how an
/// LLM previews a room it just authored.
fn render_room(game_dir: &str, input: &str, output: &str) {
let text = std::fs::read_to_string(input).unwrap_or_else(|e| panic!("can't read {}: {}", input, e));
let doc: mrpgi_core::RoomDoc = serde_json::from_str(&text).unwrap_or_else(|e| panic!("{} isn't a RoomDoc: {}", input, e));
let sprites = mrpgi_core::assets::load_sprite_dir(
&std::path::Path::new(game_dir).join("sprites").to_string_lossy(),
);
let mut fb = mrpgi_core::framebuffer::FrameBuffer::new();
for s in &doc.strokes {
mrpgi_core::world::apply_stroke(&mut fb, s, &sprites);
}
let props: Vec<mrpgi_core::sprite::Prop> = doc
.objects
.iter()
.filter_map(|o| {
sprites.iter().find(|(n, _)| *n == o.sprite).map(|(_, cel)| mrpgi_core::sprite::Prop {
x: o.x,
y: o.y,
cel: cel.clone(),
fixed_priority: None,
name: o.name.clone(),
})
})
.collect();
let rgba = mrpgi_core::render::compose(&fb, None, &props);
std::fs::write(output, mrpgi_core::render::encode_png(&rgba)).unwrap_or_else(|e| panic!("can't write {}: {}", output, e));
eprintln!("rendered {} -> {}", input, output);
}
const HELP: &str = "\
mrpgi-headless — drive the MRPGI engine with no window.
mrpgi-headless [--game DIR] JSONL REPL: commands on stdin, events on stdout
mrpgi-headless --script FILE [--game DIR] replay a JSONL command log (AI lane off by default)
mrpgi-headless --render-room IN.json OUT.png [--game DIR]
mrpgi-headless --serve [ADDR] [--game DIR] HTTP: POST /command, GET /state|/frame.png|/events?since=N
mrpgi-headless --mcp [--game DIR] MCP server on stdio (point Claude at this)
mrpgi-headless --sample|--kit [--game DIR] write starter game content
Commands are JSON, one per line, e.g.
{\"cmd\":\"parse\",\"text\":\"take key\"}
{\"cmd\":\"walk_to\",\"x\":150,\"y\":90}
{\"cmd\":\"tick\",\"n\":20}
{\"cmd\":\"query\"}
";