163 lines
6.2 KiB
Rust
163 lines
6.2 KiB
Rust
//! mrpci-headless — every control surface for the MRPCI engine in one
|
|
//! binary: JSONL stdio (the substrate), --serve HTTP, --mcp for Claude,
|
|
//! --script replay for golden tests, --render-room for LLM eyes, --sample
|
|
//! to write the demo game.
|
|
|
|
mod http;
|
|
mod mcp;
|
|
mod sample;
|
|
|
|
use mrpci_core::{Command, Event, GameState, World};
|
|
use std::io::{BufRead, Write};
|
|
|
|
fn main() {
|
|
let args: Vec<String> = std::env::args().collect();
|
|
let get = |flag: &str| -> Option<String> {
|
|
args.iter().position(|a| a == flag).and_then(|i| args.get(i + 1).cloned())
|
|
};
|
|
let has = |flag: &str| args.iter().any(|a| a == flag);
|
|
|
|
if has("--help") || has("-h") {
|
|
println!(
|
|
"mrpci-headless — the MRPCI engine, no window\n\n\
|
|
USAGE:\n mrpci-headless [--game DIR] [MODE]\n\n\
|
|
MODES (default: JSONL stdio — one JSON command per line):\n\
|
|
--serve [PORT] HTTP surface (default 8093)\n\
|
|
--mcp MCP server on stdio (for Claude)\n\
|
|
--script FILE replay a JSONL command log, print events\n\
|
|
--render-room FILE render the current room to a PNG and exit\n\
|
|
--screens DIR dump visual/priority/control/hotspot PNGs\n\
|
|
--sample write the demo game into games/neon-precinct\n\
|
|
--seed N seed the deterministic RNG\n\
|
|
--room N jump to room N at boot (with --render-room etc)\n\
|
|
--ticks N advance N cycles at boot (palette cycling, NPCs)\n\
|
|
--bundle FILE boot from a single-JSON world bundle (the wasm path)\n\
|
|
--export-bundle F write the loaded game as one bundle JSON and exit\n"
|
|
);
|
|
return;
|
|
}
|
|
|
|
if has("--sample") {
|
|
let dir = get("--sample-dir").unwrap_or_else(|| "games/neon-precinct".into());
|
|
match sample::write_sample(&dir) {
|
|
Ok(()) => println!("sample game written to {}", dir),
|
|
Err(e) => {
|
|
eprintln!("sample write failed: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
let game_dir = get("--game").unwrap_or_else(|| ".".into());
|
|
// `--bundle FILE` boots from a single-JSON world instead of a folder —
|
|
// the exact path a filesystem-less (wasm) build takes, testable here.
|
|
let world = match get("--bundle") {
|
|
Some(path) => {
|
|
let s = std::fs::read_to_string(&path).unwrap_or_else(|e| {
|
|
eprintln!("can't read bundle {}: {}", path, e);
|
|
std::process::exit(1);
|
|
});
|
|
let bundle: mrpci_core::WorldBundle = serde_json::from_str(&s).unwrap_or_else(|e| {
|
|
eprintln!("bad bundle {}: {}", path, e);
|
|
std::process::exit(1);
|
|
});
|
|
let mut sprites: Vec<_> = bundle.sprites.clone().into_iter().collect();
|
|
sprites.sort_by(|a, b| a.0.cmp(&b.0));
|
|
World::from_memory(
|
|
bundle.manifest,
|
|
bundle.rooms,
|
|
bundle.scripts,
|
|
sprites,
|
|
)
|
|
}
|
|
None => World::load_game(&game_dir),
|
|
};
|
|
let mut gs = GameState::new(world);
|
|
|
|
if let Some(path) = get("--export-bundle") {
|
|
let bundle = gs.world.to_bundle();
|
|
let j = serde_json::to_string(&bundle).expect("bundle serializes");
|
|
std::fs::write(&path, &j).expect("write bundle");
|
|
println!("bundle exported: {} ({} rooms, {} scripts, {} sprites, {} bytes)",
|
|
path, bundle.rooms.len(), bundle.scripts.len(), bundle.sprites.len(), j.len());
|
|
return;
|
|
}
|
|
if let Some(seed) = get("--seed").and_then(|s| s.parse::<u64>().ok()) {
|
|
gs.apply(Command::SetSeed { seed });
|
|
}
|
|
if let Some(n) = get("--room").and_then(|s| s.parse::<u32>().ok()) {
|
|
gs.apply(Command::GotoRoom { n });
|
|
}
|
|
if let Some(n) = get("--ticks").and_then(|s| s.parse::<u32>().ok()) {
|
|
gs.apply(Command::Tick { n });
|
|
}
|
|
|
|
if let Some(path) = get("--render-room") {
|
|
let png = if has("--raw") { gs.render_png(true) } else { gs.render_png_annotated() };
|
|
std::fs::write(&path, png).expect("write png");
|
|
println!("rendered {} ({} room {})", path, gs.world.manifest.name, gs.world.current);
|
|
return;
|
|
}
|
|
|
|
if let Some(dir) = get("--screens") {
|
|
std::fs::create_dir_all(&dir).expect("mkdir");
|
|
std::fs::write(format!("{}/visual.png", dir), gs.render_png(true)).unwrap();
|
|
for which in ["priority", "control", "hotspot"] {
|
|
let rgba = mrpci_core::render::debug_screen(&gs.world.screens, which);
|
|
std::fs::write(format!("{}/{}.png", dir, which), mrpci_core::render::encode_png(&rgba)).unwrap();
|
|
}
|
|
println!("screens dumped to {}", dir);
|
|
return;
|
|
}
|
|
|
|
if has("--mcp") {
|
|
mcp::serve(gs);
|
|
return;
|
|
}
|
|
|
|
if has("--serve") {
|
|
let port: u16 = get("--serve").and_then(|p| p.parse().ok()).unwrap_or(8093);
|
|
http::serve(gs, port);
|
|
return;
|
|
}
|
|
|
|
if let Some(path) = get("--script") {
|
|
let f = std::fs::File::open(&path).unwrap_or_else(|e| {
|
|
eprintln!("can't open {}: {}", path, e);
|
|
std::process::exit(1);
|
|
});
|
|
for line in std::io::BufReader::new(f).lines().map_while(Result::ok) {
|
|
run_line(&mut gs, &line);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// The substrate: JSONL on stdio.
|
|
let stdin = std::io::stdin();
|
|
for line in stdin.lock().lines().map_while(Result::ok) {
|
|
run_line(&mut gs, &line);
|
|
}
|
|
}
|
|
|
|
fn run_line(gs: &mut GameState, line: &str) {
|
|
let line = line.trim();
|
|
if line.is_empty() || line.starts_with('#') {
|
|
return;
|
|
}
|
|
let out = std::io::stdout();
|
|
let mut out = out.lock();
|
|
match serde_json::from_str::<Command>(line) {
|
|
Ok(cmd) => {
|
|
for ev in gs.apply(cmd) {
|
|
let _ = writeln!(out, "{}", serde_json::to_string(&ev).unwrap_or_default());
|
|
}
|
|
}
|
|
Err(e) => {
|
|
let ev = Event::Error { message: format!("bad command: {}", e) };
|
|
let _ = writeln!(out, "{}", serde_json::to_string(&ev).unwrap_or_default());
|
|
}
|
|
}
|
|
let _ = out.flush();
|
|
}
|