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>
159 lines
5.9 KiB
Rust
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\"}
|
|
";
|