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>
279 lines
14 KiB
Rust
279 lines
14 KiB
Rust
//! Starter content, built with the real types so the JSON is always valid:
|
|
//! a 3-room sample game, the fantasy kit (archetype catalog + prefab
|
|
//! locations + a connected world), and the archetype loader the editor uses.
|
|
|
|
use crate::assets;
|
|
use crate::world::{GameManifest, Meaning, ObjDef, RoomDoc, Stroke};
|
|
use std::path::Path;
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn obj(name: &str, sprite: &str, x: i32, y: i32, look: &str, takeable: bool, needs: &str, use_text: &str, wins: bool, syn: &str) -> ObjDef {
|
|
ObjDef {
|
|
name: name.into(),
|
|
sprite: sprite.into(),
|
|
x,
|
|
y,
|
|
look: look.into(),
|
|
takeable,
|
|
synonyms: syn.into(),
|
|
use_text: use_text.into(),
|
|
needs: needs.into(),
|
|
wins,
|
|
requires_flag: String::new(),
|
|
sets_flag: String::new(),
|
|
dialogue: Vec::new(),
|
|
}
|
|
}
|
|
|
|
fn write_room(base: &Path, i: usize, room: &RoomDoc) {
|
|
let path = base.join(format!("rooms/room{}.json", i));
|
|
if path.exists() {
|
|
std::fs::rename(&path, path.with_extension("json.bak")).ok();
|
|
}
|
|
if let Ok(j) = serde_json::to_string_pretty(room) {
|
|
std::fs::write(&path, j).ok();
|
|
}
|
|
}
|
|
|
|
fn write_manifest(base: &Path, name: &str, intro: &str) {
|
|
let m = GameManifest { name: name.into(), intro_text: intro.into(), ..Default::default() };
|
|
if let Ok(j) = serde_json::to_string_pretty(&m) {
|
|
std::fs::write(base.join("game.json"), j).ok();
|
|
}
|
|
}
|
|
|
|
/// Write a fun little 3-room sample game into `base` (rooms/ + game.json).
|
|
pub fn write_sample_world(base: impl AsRef<Path>) {
|
|
let base = base.as_ref();
|
|
std::fs::create_dir_all(base.join("rooms")).ok();
|
|
assets::ensure_sample_sprites(&base.join("sprites").to_string_lossy());
|
|
|
|
let wall = |x: i32, y: i32, w: i32, h: i32| Stroke::Rect { x, y, w, h, color: 0, meaning: Meaning::Wall };
|
|
let paint = |x: i32, y: i32, w: i32, h: i32, c: u8| Stroke::Rect { x, y, w, h, color: c, meaning: Meaning::None };
|
|
let floor = |x: i32, y: i32, w: i32, h: i32, c: u8| Stroke::Rect { x, y, w, h, color: c, meaning: Meaning::Floor };
|
|
|
|
// Room 0 — the Boot-Up Closet (exit EAST to room 1)
|
|
let room0 = RoomDoc {
|
|
strokes: vec![
|
|
paint(0, 0, 160, 168, 8),
|
|
floor(6, 16, 148, 146, 7),
|
|
wall(0, 0, 160, 16),
|
|
wall(0, 160, 160, 8),
|
|
wall(0, 0, 6, 168),
|
|
wall(154, 0, 6, 70),
|
|
wall(154, 100, 6, 68),
|
|
paint(64, 36, 28, 12, 13),
|
|
],
|
|
spawn: (24, 140),
|
|
exits: [None, Some(1), None, None],
|
|
objects: vec![
|
|
obj("battery", "barrel", 40, 124, "A fat 9-volt, warm to the touch. Still a little zap left in it.", true, "", "", false, "cell power"),
|
|
obj("crate", "crate", 120, 134, "A crate stamped MONSTER ROBOT PARTY. It rattles when you nudge it.", false, "", "", false, "box"),
|
|
obj("sign", "sign", 78, 56, "A flickering sign: -> THIS WAY TO THE PARTY.", false, "", "", false, "arrow notice"),
|
|
],
|
|
music: 4,
|
|
};
|
|
|
|
// Room 1 — the Party Hall (WEST to 0, NORTH to 2)
|
|
let room1 = RoomDoc {
|
|
strokes: vec![
|
|
paint(0, 0, 160, 168, 1),
|
|
floor(6, 16, 148, 146, 8),
|
|
wall(0, 0, 65, 16),
|
|
wall(95, 0, 65, 16),
|
|
wall(0, 160, 160, 8),
|
|
wall(154, 0, 6, 168),
|
|
wall(0, 0, 6, 70),
|
|
wall(0, 100, 6, 68),
|
|
paint(20, 28, 26, 8, 13),
|
|
paint(112, 26, 28, 8, 11),
|
|
paint(66, 22, 28, 6, 14),
|
|
],
|
|
spawn: (80, 140),
|
|
exits: [Some(2), None, None, Some(0)],
|
|
objects: vec![
|
|
obj("robot", "crate", 60, 124, "Another little robot, vibing hard to a beat only it can hear. It will not be interrupted.", false, "", "", false, "bot dude guy"),
|
|
obj("speaker", "crate", 122, 118, "A speaker the size of a fridge. Your chassis rattles, pleasantly.", false, "", "", false, "amp boombox"),
|
|
obj("punch", "barrel", 30, 142, "A barrel of suspicious party punch. You have no mouth, but you respect the commitment.", false, "", "", false, "drink barrel"),
|
|
],
|
|
music: 4,
|
|
};
|
|
|
|
// Room 2 — the Exit Hatch (SOUTH back to 1) — the win room
|
|
let room2 = RoomDoc {
|
|
strokes: vec![
|
|
paint(0, 0, 160, 168, 0),
|
|
floor(6, 16, 148, 146, 8),
|
|
wall(0, 0, 160, 16),
|
|
wall(0, 0, 6, 168),
|
|
wall(154, 0, 6, 168),
|
|
wall(0, 160, 65, 8),
|
|
wall(95, 160, 65, 8),
|
|
paint(60, 30, 40, 10, 10),
|
|
],
|
|
spawn: (80, 140),
|
|
exits: [None, None, Some(1), None],
|
|
objects: vec![obj(
|
|
"hatch",
|
|
"sign",
|
|
80,
|
|
98,
|
|
"A heavy exit hatch with a dead battery slot. It won't budge without power.",
|
|
false,
|
|
"battery",
|
|
"You slam the battery into the slot. The hatch grinds open and cold night air rushes in. You roll out into the dark. YOU ESCAPED THE PARTY. What a night.",
|
|
true,
|
|
"door gate exit",
|
|
)],
|
|
music: 2,
|
|
};
|
|
|
|
for (i, room) in [room0, room1, room2].iter().enumerate() {
|
|
write_room(base, i, room);
|
|
}
|
|
write_manifest(base, "Monster Robot Party", "You blink awake. Type 'help'.");
|
|
}
|
|
|
|
/// Build a standard walled room: backdrop + walkable floor + border walls,
|
|
/// leaving a centered gap on each edge listed in `gaps` (0=N,1=E,2=S,3=W).
|
|
fn walled(bg: u8, fl: u8, wc: u8, gaps: &[u8]) -> Vec<Stroke> {
|
|
let w = |x: i32, y: i32, ww: i32, hh: i32| Stroke::Rect { x, y, w: ww, h: hh, color: wc, meaning: Meaning::Wall };
|
|
let mut v = vec![
|
|
Stroke::Rect { x: 0, y: 0, w: 160, h: 168, color: bg, meaning: Meaning::None },
|
|
Stroke::Rect { x: 6, y: 16, w: 148, h: 146, color: fl, meaning: Meaning::Floor },
|
|
];
|
|
if gaps.contains(&0) { v.push(w(0, 0, 65, 16)); v.push(w(95, 0, 65, 16)); } else { v.push(w(0, 0, 160, 16)); }
|
|
if gaps.contains(&2) { v.push(w(0, 160, 65, 8)); v.push(w(95, 160, 65, 8)); } else { v.push(w(0, 160, 160, 8)); }
|
|
if gaps.contains(&3) { v.push(w(0, 0, 6, 70)); v.push(w(0, 100, 6, 68)); } else { v.push(w(0, 0, 6, 168)); }
|
|
if gaps.contains(&1) { v.push(w(154, 0, 6, 70)); v.push(w(154, 100, 6, 68)); } else { v.push(w(154, 0, 6, 168)); }
|
|
v
|
|
}
|
|
|
|
/// Write the fantasy starter kit into `base`: placeholder sprites, an
|
|
/// object-archetype catalog (kit/objects.json), prefab locations
|
|
/// (kit/locations/*.json), and a connected playable world installed to
|
|
/// rooms/ (existing rooms → .bak).
|
|
pub fn write_kit(base: impl AsRef<Path>) {
|
|
let base = base.as_ref();
|
|
assets::write_kit_sprites(&base.join("sprites").to_string_lossy());
|
|
std::fs::create_dir_all(base.join("kit/locations")).ok();
|
|
std::fs::create_dir_all(base.join("rooms")).ok();
|
|
|
|
// Object archetype catalog — copy any of these into a room's `objects`.
|
|
let catalog: Vec<ObjDef> = vec![
|
|
obj("sword", "sword", 80, 130, "A notched iron sword. It has Opinions about orcs.", true, "", "", false, "blade weapon"),
|
|
obj("spellbook", "spellbook", 80, 130, "A spellbook bound in something best not identified. The runes won't hold still.", true, "", "", false, "book tome grimoire"),
|
|
obj("key", "key", 80, 130, "A heavy iron key, cold and important-looking.", true, "", "", false, "ironkey"),
|
|
obj("potion", "potion", 80, 130, "A vial of bubbling red liquid. Almost certainly fine.", true, "", "", false, "vial elixir flask"),
|
|
obj("torch", "torch", 80, 130, "A guttering torch. It pushes the dark back a little.", true, "", "", false, "light flame"),
|
|
obj("orc", "orc", 80, 120, "A green orc, picking its teeth with a dagger. It hasn't noticed you. Yet.", false, "", "You poke the orc. It looks up. In hindsight, a mistake.", false, "monster brute goblin"),
|
|
obj("villager", "villager", 80, 120, "A nervous villager. 'You're... not from the cave, are you?'", false, "", "'Sorry, nothing for you,' the villager mumbles.", false, "person man woman npc"),
|
|
obj("chest", "chest", 80, 135, "A banded wooden chest. Locked.", false, "key", "The key turns; the lid yawns open and old gold spills out.", false, "box trunk"),
|
|
obj("door", "door", 80, 120, "A stout wooden door, banded with iron.", false, "key", "The lock clunks and the door swings wide.", false, "gate"),
|
|
obj("altar", "altar", 80, 100, "A stone altar humming with cold light. Something is meant to rest here.", false, "spellbook", "You lay the spellbook on the altar. A doorway tears open in the air. THE END.", true, "shrine pedestal"),
|
|
obj("tree", "tree", 60, 80, "An ancient tree, bark like old leather.", false, "", "", false, "oak trunk wood"),
|
|
obj("rock", "rock", 120, 130, "A mossy boulder. Immovable, and a little smug about it.", false, "", "", false, "boulder stone"),
|
|
obj("sign", "sign", 80, 50, "A weathered signpost, paint long faded.", false, "", "", false, "notice post"),
|
|
];
|
|
if let Ok(j) = serde_json::to_string_pretty(&catalog) {
|
|
std::fs::write(base.join("kit/objects.json"), j).ok();
|
|
}
|
|
|
|
// Connected world: 0 forest, 1 cave, 2 dungeon, 3 inn.
|
|
let mut s = walled(2, 10, 6, &[1, 2]);
|
|
s.push(Stroke::Rect { x: 30, y: 120, w: 18, h: 8, color: 2, meaning: Meaning::None });
|
|
s.push(Stroke::Rect { x: 100, y: 60, w: 22, h: 8, color: 2, meaning: Meaning::None });
|
|
let forest = RoomDoc {
|
|
strokes: s,
|
|
spawn: (24, 130),
|
|
exits: [None, Some(1), Some(3), None],
|
|
objects: vec![
|
|
obj("tree", "tree", 44, 70, "An ancient tree, bark like old leather.", false, "", "", false, "oak trunk wood"),
|
|
obj("sword", "sword", 80, 132, "A notched iron sword someone left in the grass. Finders keepers.", true, "", "", false, "blade weapon"),
|
|
obj("rock", "rock", 120, 134, "A mossy boulder, smug and immovable.", false, "", "", false, "boulder stone"),
|
|
obj("sign", "sign", 80, 44, "A signpost: CAVE east, INN south.", false, "", "", false, "notice post"),
|
|
],
|
|
music: 1,
|
|
};
|
|
|
|
let mut s = walled(0, 8, 0, &[0, 3]);
|
|
s.push(Stroke::Rect { x: 40, y: 60, w: 10, h: 6, color: 7, meaning: Meaning::None });
|
|
s.push(Stroke::Rect { x: 110, y: 80, w: 12, h: 6, color: 7, meaning: Meaning::None });
|
|
let cave = RoomDoc {
|
|
strokes: s,
|
|
spawn: (80, 130),
|
|
exits: [Some(2), None, None, Some(0)],
|
|
objects: vec![
|
|
obj("orc", "orc", 92, 118, "A green orc picking its teeth with a dagger. It hasn't noticed you. Yet.", false, "", "You poke the orc. This was a mistake.", false, "monster brute goblin"),
|
|
obj("spellbook", "spellbook", 122, 132, "A spellbook on a ledge, runes squirming. The orc is guarding it (badly).", true, "", "", false, "book tome grimoire"),
|
|
obj("torch", "torch", 36, 110, "A guttering wall-torch. Grab it — it's dark deeper in.", true, "", "", false, "light flame"),
|
|
obj("chest", "chest", 58, 140, "A banded chest, half-buried. Locked.", false, "key", "The key turns; old gold spills out across the cave floor.", false, "box trunk"),
|
|
],
|
|
music: 2,
|
|
};
|
|
|
|
let mut s = walled(0, 8, 0, &[2]);
|
|
s.push(Stroke::Rect { x: 58, y: 30, w: 44, h: 10, color: 13, meaning: Meaning::None });
|
|
let dungeon = RoomDoc {
|
|
strokes: s,
|
|
spawn: (80, 138),
|
|
exits: [None, None, Some(1), None],
|
|
objects: vec![
|
|
obj("altar", "altar", 80, 98, "A stone altar humming with cold light. A book-shaped hollow waits in its surface.", false, "spellbook", "You lay the spellbook on the altar. Light pours up, a doorway tears open, and you step through. YOU ESCAPED — adventure complete. THE END.", true, "shrine pedestal"),
|
|
obj("torch", "torch", 30, 120, "A torch in a rusted bracket.", true, "", "", false, "light flame"),
|
|
],
|
|
music: 3,
|
|
};
|
|
|
|
let mut s = walled(6, 7, 6, &[0]);
|
|
s.push(Stroke::Rect { x: 40, y: 122, w: 80, h: 28, color: 4, meaning: Meaning::None });
|
|
s.push(Stroke::Rect { x: 20, y: 24, w: 8, h: 8, color: 14, meaning: Meaning::None });
|
|
let inn = RoomDoc {
|
|
strokes: s,
|
|
spawn: (80, 138),
|
|
exits: [Some(0), None, None, None],
|
|
objects: vec![
|
|
obj("villager", "villager", 60, 112, "A nervous barkeep. 'Heading to the cave? Take the key — and my prayers.'", false, "", "'Be careful in there,' the barkeep says, sliding you nothing useful.", false, "barkeep person npc"),
|
|
obj("key", "key", 44, 140, "An iron key hanging by the door. The barkeep nods at you to take it.", true, "", "", false, "ironkey"),
|
|
obj("potion", "potion", 112, 132, "A complimentary vial of bubbling red liquid. Probably fine.", true, "", "", false, "vial elixir flask"),
|
|
obj("barrel", "barrel", 122, 120, "A barrel of inn ale. Smells like courage and mistakes.", false, "", "", false, "ale keg"),
|
|
],
|
|
music: 4,
|
|
};
|
|
|
|
let rooms: [(&str, &RoomDoc); 4] = [("forest", &forest), ("cave", &cave), ("dungeon", &dungeon), ("inn", &inn)];
|
|
for (name, r) in rooms.iter() {
|
|
if let Ok(j) = serde_json::to_string_pretty(*r) {
|
|
std::fs::write(base.join(format!("kit/locations/{}.json", name)), j).ok();
|
|
}
|
|
}
|
|
for (i, (_n, r)) in rooms.iter().enumerate() {
|
|
write_room(base, i, r);
|
|
}
|
|
write_manifest(base, "The Altar Quest", "You wake in a quiet forest. Type 'help'.");
|
|
}
|
|
|
|
/// Load every object archetype the kit ships (fantasy `kit/objects.json` +
|
|
/// every `kit/themes/*/objects.json`) so the editor can stamp them by clicking.
|
|
pub fn load_archetypes(base: impl AsRef<Path>) -> Vec<ObjDef> {
|
|
let base = base.as_ref();
|
|
let mut files = vec![base.join("kit/objects.json")];
|
|
if let Ok(rd) = std::fs::read_dir(base.join("kit/themes")) {
|
|
for e in rd.flatten() {
|
|
let p = e.path().join("objects.json");
|
|
if p.exists() {
|
|
files.push(p);
|
|
}
|
|
}
|
|
}
|
|
let mut out = Vec::new();
|
|
for f in files {
|
|
if let Ok(s) = std::fs::read_to_string(&f) {
|
|
if let Ok(v) = serde_json::from_str::<Vec<ObjDef>>(&s) {
|
|
out.extend(v);
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|