//! 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 = std::env::args().skip(1).collect(); let get_opt = |flag: &str| -> Option { 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::(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 = 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\"} ";