MRPGI/mrpgi-core/src/world.rs
type-two 755168a969 Sierra soul for game authors: scoring, deaths, gated exits, room prose, visibility flags
New authoring surface (all serde-defaulted; old games load unchanged):
- RoomDoc: name + enter_text (announced on entry), exit_flags/exit_blocked
  (per-direction flag gates with authored refusal lines, latched per attempt)
- ObjDef: points (once per room+name), consumes, kills, visible_flag,
  hidden_by_flag; DlgChoice: points (latched on sets_flag flip), kills
  (goto node's says = death text)
- GameManifest: max_score enables scoring; GUI shows the classic
  'Score: X of Y' in the menu bar; score_changed + died on the bus
- Death restarts the day, keeping the cause at the top of the fresh transcript

Reviewed by an adversarial multi-agent pass; confirmed findings fixed:
corner push-back no longer slingshots through a perpendicular open exit
(and checks the landing pixel), blocked messages latch per attempt,
kill choices pay no points/flags, new_game re-zeroes the score on the bus,
AI-lane prompts respect object visibility. Deliberate behavior change:
drop/consumes now emit inventory_changed (was a silent client desync).

Built for MORPQUEST (games/… ssh://…/monster/mrpquest.git), which uses all
of it: 250-point budget, four gated exits, two deaths, vanishing Beckies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 15:30:32 +10:00

521 lines
18 KiB
Rust

//! The world data model — rooms, objects, dialogue, strokes — and [`World`],
//! the single source of truth for "what game is loaded and which room are we
//! in". Everything here is serde: a game is a folder of JSON + PNGs, and an
//! LLM expansion is just JSON that deserializes.
use crate::assets;
use crate::framebuffer::{FrameBuffer, CTRL_BLOCK, PIC_H, PIC_W};
use crate::parser::VerbDef;
use crate::picture::{self, PriFill};
use crate::view::{self, Cel};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)]
pub enum Meaning {
None,
Floor,
Wall,
Water,
Trigger,
}
impl Meaning {
pub fn pri(self) -> PriFill {
match self {
Meaning::None => PriFill::Keep,
Meaning::Floor => PriFill::Band,
Meaning::Wall => PriFill::Set(CTRL_BLOCK),
Meaning::Water => PriFill::Set(1),
Meaning::Trigger => PriFill::Set(2),
}
}
pub fn label(self) -> &'static str {
match self {
Meaning::None => "none",
Meaning::Floor => "floor",
Meaning::Wall => "wall",
Meaning::Water => "water",
Meaning::Trigger => "trigger",
}
}
}
/// One editable paint operation. A room's look + walkability is the replayable
/// list of these — the modern echo of AGI's PICTURE opcode streams.
#[derive(Clone, Serialize, Deserialize)]
pub enum Stroke {
Brush { pts: Vec<(i32, i32)>, color: u8, meaning: Meaning, size: i32 },
Line { pts: Vec<(i32, i32)>, color: u8 },
Rect { x: i32, y: i32, w: i32, h: i32, color: u8, meaning: Meaning },
Ellipse { x: i32, y: i32, w: i32, h: i32, color: u8, meaning: Meaning },
Fill { x: i32, y: i32, color: u8, meaning: Meaning },
Image { name: String, x: i32, y: i32, meaning: Meaning },
}
#[derive(Clone, Default, Serialize, Deserialize)]
pub struct DlgChoice {
pub text: String,
pub goto: i32, // next node index, or -1 to end the conversation
/// Only offered while this flag is true (empty = always offered).
#[serde(default)]
pub requires_flag: String,
/// Picking this choice sets this flag true (empty = none).
#[serde(default)]
pub sets_flag: String,
/// Points awarded when this choice flips its `sets_flag` false → true
/// (the flag latch is what makes the award once-only).
#[serde(default)]
pub points: u32,
/// Picking this choice kills the player, Sierra style. The `goto` node's
/// `says` is printed as the death text, then the day restarts.
#[serde(default)]
pub kills: bool,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct DlgNode {
pub says: String,
#[serde(default)]
pub choices: Vec<DlgChoice>,
}
/// A live object: a sprite the player can look at / take / use / talk to. Its
/// base point (x, y) is its feet, like the ego, so it depth-sorts the same way.
#[derive(Clone, Default, Serialize, Deserialize)]
pub struct ObjDef {
pub name: String,
pub sprite: String,
pub x: i32,
pub y: i32,
#[serde(default)]
pub look: String,
#[serde(default)]
pub takeable: bool,
#[serde(default)]
pub synonyms: String, // space-separated alternate names
#[serde(default)]
pub use_text: String, // shown on "use"/"open"
#[serde(default)]
pub needs: String, // item required to use it (empty = no lock)
#[serde(default)]
pub wins: bool, // using it (successfully) wins the game
/// Using it also requires this flag to be true (empty = no gate).
#[serde(default)]
pub requires_flag: String,
/// Using it (successfully) sets this flag true (empty = none).
#[serde(default)]
pub sets_flag: String,
#[serde(default)]
pub dialogue: Vec<DlgNode>, // node 0 = entry; empty = no conversation
/// Points for taking it (takeable) or first successful use (otherwise).
/// Awarded once per room+name.
#[serde(default)]
pub points: u32,
/// A successful use removes the `needs` item from inventory.
#[serde(default)]
pub consumes: bool,
/// A successful use kills the player (use_text is the death text).
#[serde(default)]
pub kills: bool,
/// Object exists only while this flag is true (empty = always).
#[serde(default)]
pub visible_flag: String,
/// Object disappears once this flag is true (empty = never).
#[serde(default)]
pub hidden_by_flag: String,
}
/// Is this object currently part of the room, given taken-list and flags?
pub fn obj_visible(
o: &ObjDef,
room: u32,
taken: &[(u32, String)],
flags: &std::collections::HashMap<String, bool>,
) -> bool {
let on = |name: &str| flags.get(name).copied().unwrap_or(false);
!taken.iter().any(|(r, n)| *r == room && *n == o.name)
&& (o.visible_flag.is_empty() || on(&o.visible_flag))
&& (o.hidden_by_flag.is_empty() || !on(&o.hidden_by_flag))
}
#[derive(Clone, Serialize, Deserialize)]
pub struct RoomDoc {
pub strokes: Vec<Stroke>,
pub spawn: (i32, i32),
#[serde(default)]
pub exits: [Option<u32>; 4], // N, E, S, W → target room index
#[serde(default)]
pub objects: Vec<ObjDef>,
#[serde(default)]
pub music: u8, // 0 = silence, 1.. = ambient mood
/// Display name, shown on entry and in `look` ("" = just the number).
#[serde(default)]
pub name: String,
/// Prose printed when the ego enters the room ("" = the old stock line).
#[serde(default)]
pub enter_text: String,
/// Per-direction flag required before that exit works ("" = open). N,E,S,W.
#[serde(default = "empty4")]
pub exit_flags: [String; 4],
/// What to say when the matching exit refuses ("" = a stock line). N,E,S,W.
#[serde(default = "empty4")]
pub exit_blocked: [String; 4],
}
fn empty4() -> [String; 4] {
[String::new(), String::new(), String::new(), String::new()]
}
impl Default for RoomDoc {
fn default() -> Self {
RoomDoc {
strokes: Vec::new(),
spawn: (80, 150),
exits: [None; 4],
objects: Vec::new(),
music: 0,
name: String::new(),
enter_text: String::new(),
exit_flags: empty4(),
exit_blocked: empty4(),
}
}
}
/// `game.json` — what makes a folder a game. Every field defaults, so a bare
/// folder of rooms is already a valid game (that's how legacy projects load).
#[derive(Clone, Serialize, Deserialize)]
pub struct GameManifest {
#[serde(default = "default_name")]
pub name: String,
#[serde(default)]
pub start_room: u32,
#[serde(default = "default_intro")]
pub intro_text: String,
/// Extra verb synonyms, merged over the built-in table by canonical name.
#[serde(default)]
pub verbs: Vec<VerbDef>,
/// Initial flag values for a new game.
#[serde(default)]
pub flags: HashMap<String, bool>,
/// Total points on offer; 0 = scoring off (no HUD line, no score events).
#[serde(default)]
pub max_score: u32,
}
fn default_name() -> String {
"untitled".into()
}
fn default_intro() -> String {
"You blink awake. Type 'help'.".into()
}
impl Default for GameManifest {
fn default() -> Self {
GameManifest {
name: default_name(),
start_room: 0,
intro_text: default_intro(),
verbs: Vec::new(),
flags: HashMap::new(),
max_score: 0,
}
}
}
// --- stroke application ------------------------------------------------
/// Paint a filled disc (brush / eraser).
pub fn disc(fb: &mut FrameBuffer, cx: i32, cy: i32, size: i32, color: u8, pri: PriFill) {
for dy in -size..=size {
for dx in -size..=size {
if dx * dx + dy * dy <= size * size {
picture::px(fb, cx + dx, cy + dy, Some(color), pri);
}
}
}
}
/// Stamp a cel centered on (cx, cy); transparent pixels are skipped. `pri`
/// writes walkability under the painted pixels.
pub fn stamp(fb: &mut FrameBuffer, cel: &Cel, cx: i32, cy: i32, pri: PriFill) {
let ox = cx - cel.w as i32 / 2;
let oy = cy - cel.h as i32 / 2;
for y in 0..cel.h {
for x in 0..cel.w {
let c = cel.at(x, y);
if c == view::TRANSPARENT {
continue;
}
picture::px(fb, ox + x as i32, oy + y as i32, Some(c), pri);
}
}
}
pub fn apply_stroke(fb: &mut FrameBuffer, s: &Stroke, sprites: &[(String, Cel)]) {
match s {
Stroke::Brush { pts, color, meaning, size } => {
for &(x, y) in pts {
disc(fb, x, y, *size, *color, meaning.pri());
}
}
Stroke::Line { pts, color } => {
for w in pts.windows(2) {
picture::line_into(fb, w[0], w[1], *color, PriFill::Keep);
}
}
Stroke::Rect { x, y, w, h, color, meaning } => {
picture::rect_into(fb, *x, *y, *w, *h, Some(*color), meaning.pri());
}
Stroke::Ellipse { x, y, w, h, color, meaning } => {
picture::ellipse_into(fb, *x, *y, *w, *h, Some(*color), meaning.pri());
}
Stroke::Fill { x, y, color, meaning } => {
picture::flood_fill(fb, *x, *y, Some(*color), meaning.pri());
}
Stroke::Image { name, x, y, meaning } => {
if let Some((_, cel)) = sprites.iter().find(|(n, _)| n == name) {
stamp(fb, cel, *x, *y, meaning.pri());
}
}
}
}
/// Find a walkable spawn near `prefer`, scanning upward from the bottom.
pub fn find_spawn(fb: &FrameBuffer, prefer: (i32, i32)) -> (i32, i32) {
if fb.priority_at(prefer.0, prefer.1) != CTRL_BLOCK {
return prefer;
}
for y in (20..164).rev() {
for x in 8..152 {
if fb.priority_at(x, y) != CTRL_BLOCK {
return (x, y);
}
}
}
prefer
}
/// A whole world in one JSON document: the manifest + every room. What cloud
/// saves store and what `to_bundle`/`apply_bundle` round-trip.
#[derive(Clone, Serialize, Deserialize)]
pub struct WorldBundle {
#[serde(default)]
pub manifest: GameManifest,
#[serde(default)]
pub rooms: HashMap<u32, RoomDoc>,
}
/// The loaded game: manifest + sprites + the current room baked into a
/// framebuffer, plus an in-memory overlay of rooms that differ from disk
/// (unsaved editor work, or lore upserted live over the bus).
pub struct World {
pub base: PathBuf,
pub manifest: GameManifest,
pub current: u32,
pub doc: RoomDoc,
pub fb: FrameBuffer,
pub sprites: Vec<(String, Cel)>,
pub overlay: HashMap<u32, RoomDoc>,
}
impl World {
/// Load a game folder: `game.json` (all-defaults if absent) + `rooms/` +
/// `sprites/`. A legacy project (bare `rooms/*.json` in the cwd) is just a
/// game folder with no manifest — one code path serves both.
pub fn load_game(dir: impl Into<PathBuf>) -> Self {
let base: PathBuf = dir.into();
let manifest: GameManifest = std::fs::read_to_string(base.join("game.json"))
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
let sprites = assets::load_sprite_dir(&base.join("sprites").to_string_lossy());
let mut w = World {
base,
current: manifest.start_room,
manifest,
doc: RoomDoc::default(),
fb: FrameBuffer::new(),
sprites,
overlay: HashMap::new(),
};
let start = w.current;
// Legacy fallback: a single old-style `rooms/room.json` acts as room 0.
if !w.room_path(start).exists() && start == 0 {
let legacy = w.base.join("rooms/room.json");
if let Ok(s) = std::fs::read_to_string(legacy) {
if let Ok(doc) = serde_json::from_str::<RoomDoc>(&s) {
w.overlay.insert(0, doc);
}
}
}
w.goto_room(start);
w
}
/// Build a World entirely from memory — no filesystem. This is how the
/// browser build boots: the game folder is baked into the binary and
/// handed over here as parsed rooms + quantized sprites.
pub fn from_memory(
manifest: GameManifest,
rooms: impl IntoIterator<Item = (u32, RoomDoc)>,
sprites: Vec<(String, Cel)>,
) -> Self {
let mut w = World {
base: PathBuf::from("."),
current: manifest.start_room,
manifest,
doc: RoomDoc::default(),
fb: FrameBuffer::new(),
sprites,
overlay: rooms.into_iter().collect(),
};
let start = w.current;
w.goto_room(start);
w
}
pub fn room_path(&self, n: u32) -> PathBuf {
self.base.join(format!("rooms/room{}.json", n))
}
/// Snapshot the whole world as one JSON document — manifest + every room
/// (disk, overlay, and the live current room). The single-file project
/// format used by cloud saves and shareable world files.
pub fn to_bundle(&self) -> WorldBundle {
let mut rooms: HashMap<u32, RoomDoc> = HashMap::new();
// rooms on disk
if let Ok(rd) = std::fs::read_dir(self.base.join("rooms")) {
for e in rd.flatten() {
let name = e.file_name().to_string_lossy().into_owned();
if let Some(n) = name.strip_prefix("room").and_then(|s| s.strip_suffix(".json")).and_then(|s| s.parse().ok()) {
if let Some(doc) = self.peek_room(n) {
rooms.insert(n, doc);
}
}
}
}
// overlay wins over disk; the live doc wins over everything
for (n, doc) in &self.overlay {
rooms.insert(*n, doc.clone());
}
rooms.insert(self.current, self.doc.clone());
WorldBundle { manifest: self.manifest.clone(), rooms }
}
/// Replace the loaded world with a bundle (sprites are kept — bundles
/// carry world data, not art). The caller decides what to do next
/// (usually `Command::NewGame`).
pub fn apply_bundle(&mut self, b: WorldBundle) {
self.manifest = b.manifest;
self.overlay = b.rooms.into_iter().collect();
let start = self.manifest.start_room;
self.goto_room(start);
}
/// Read a room doc without switching to it: overlay first, then disk.
pub fn peek_room(&self, n: u32) -> Option<RoomDoc> {
if let Some(doc) = self.overlay.get(&n) {
return Some(doc.clone());
}
std::fs::read_to_string(self.room_path(n)).ok().and_then(|s| serde_json::from_str(&s).ok())
}
/// Switch the working room WITHOUT saving. Overlay wins over disk; a
/// missing room is a fresh blank one.
pub fn goto_room(&mut self, n: u32) {
self.current = n;
self.doc = self.peek_room(n).unwrap_or_default();
self.bake();
}
/// Replay the current room's strokes into the framebuffer.
pub fn bake(&mut self) {
self.fb.clear();
for s in &self.doc.strokes {
apply_stroke(&mut self.fb, s, &self.sprites);
}
}
/// Stash the current room into the overlay (so leaving and returning
/// keeps unsaved changes) — used before playtesting live edits.
pub fn stash_current(&mut self) {
self.overlay.insert(self.current, self.doc.clone());
}
/// Insert/replace a room. If it's the current room the framebuffer rebakes.
/// With `persist` it is also written to disk (and dropped from the overlay).
pub fn upsert_room(&mut self, n: u32, doc: RoomDoc, persist: bool) -> std::io::Result<()> {
if persist {
self.write_room(n, &doc)?;
self.overlay.remove(&n);
} else {
self.overlay.insert(n, doc.clone());
}
if n == self.current {
self.doc = doc;
self.bake();
}
Ok(())
}
pub fn write_room(&self, n: u32, doc: &RoomDoc) -> std::io::Result<()> {
let path = self.room_path(n);
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let j = serde_json::to_string_pretty(doc).map_err(std::io::Error::other)?;
std::fs::write(path, j)
}
/// Save the current room to disk.
pub fn save_current(&mut self) -> std::io::Result<()> {
self.write_room(self.current, &self.doc.clone())?;
self.overlay.remove(&self.current);
Ok(())
}
pub fn sprite_cel(&self, name: &str) -> Option<&Cel> {
self.sprites.iter().find(|(n, _)| n == name).map(|(_, c)| c)
}
pub fn reload_sprites(&mut self) {
self.sprites = assets::load_sprite_dir(&self.base.join("sprites").to_string_lossy());
self.bake();
}
/// Sanity-check a room before committing it (the lore-ingestion gate):
/// exits must point at rooms that exist (on disk, in the overlay, or being
/// upserted alongside), and the room must have somewhere to stand.
pub fn validate_room(&self, n: u32, doc: &RoomDoc) -> Result<(), String> {
let mut fb = FrameBuffer::new();
for s in &doc.strokes {
apply_stroke(&mut fb, s, &self.sprites);
}
let (sx, sy) = find_spawn(&fb, doc.spawn);
if fb.priority_at(sx, sy) == CTRL_BLOCK {
return Err(format!("room {} has no walkable spawn — it's all wall", n));
}
for (d, t) in doc.exits.iter().enumerate() {
if let Some(t) = t {
let known = *t == n || self.overlay.contains_key(t) || self.room_path(*t).exists();
if !known {
return Err(format!(
"room {} exit {} points at room {}, which doesn't exist yet — upsert it first",
n,
["N", "E", "S", "W"][d],
t
));
}
}
}
Ok(())
}
}
// Keep constants nearby for consumers that reason about room geometry.
pub const ROOM_W: usize = PIC_W;
pub const ROOM_H: usize = PIC_H;