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>
521 lines
18 KiB
Rust
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;
|