From 755168a96924975515fc09e002825b2396903b3f Mon Sep 17 00:00:00 2001 From: type-two Date: Sun, 5 Jul 2026 15:30:32 +1000 Subject: [PATCH] Sierra soul for game authors: scoring, deaths, gated exits, room prose, visibility flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/CONTROL.md | 24 +++- mrpgi-core/src/kit.rs | 11 +- mrpgi-core/src/parser.rs | 101 +++++++++++---- mrpgi-core/src/state.rs | 157 ++++++++++++++++++++++-- mrpgi-core/src/world.rs | 73 ++++++++++- mrpgi-core/tests/engine.rs | 245 ++++++++++++++++++++++++++++++++----- mrpgi/src/editor.rs | 8 +- mrpgi/src/main.rs | 6 + 8 files changed, 543 insertions(+), 82 deletions(-) diff --git a/docs/CONTROL.md b/docs/CONTROL.md index 821b8ef..bedb6f4 100644 --- a/docs/CONTROL.md +++ b/docs/CONTROL.md @@ -129,6 +129,9 @@ The loop that makes a whole game from prompts: upsert rooms/objects as JSON → **RoomDoc** — `{"strokes":[...], "spawn":[x,y], "exits":[N,E,S,W], "objects":[...], "music":0-5}` (exit entries are room numbers or `null`; music: 0 off, 1 calm, 2 eerie, 3 tense, 4 jolly, 5 spooky) +plus optional `name` (shown on entry and in `look`), `enter_text` (entry prose), +`exit_flags` (`["","side-door-open","",""]` — that direction refuses until the +flag is true) and `exit_blocked` (what refusing says; "" = a stock line). **Stroke** (paint ops; `meaning` is `"None" | "Floor" | "Wall" | "Water" | "Trigger"`, colors are EGA 0-15): @@ -145,11 +148,26 @@ The loop that makes a whole game from prompts: upsert rooms/objects as JSON → (space-separated), `use_text`, `needs` (item that unlocks it), `wins`, `requires_flag` / `sets_flag` (puzzle gating), `dialogue` (nodes of `{"says":"...","choices":[{"text":"...","goto":1,"requires_flag":"","sets_flag":""}]}`, -`goto:-1` ends). +`goto:-1` ends). Sierra extras, all optional: `points` (awarded once per room — +on take for takeables, on first successful use otherwise), `consumes` (a +successful use removes the `needs` item from inventory), `kills` (a successful +use is death: the day restarts, `use_text` is the death text), `visible_flag` +(object exists only while the flag is true) and `hidden_by_flag` (object +disappears once the flag is true). Dialogue choices also take `points` +(awarded when their `sets_flag` first flips true — the flag is the latch) and +`kills` (death; the `goto` node's `says` is printed as the death text). +Precedence: `kills` preempts `points`/`wins`/`sets_flag` — a death pays +nothing and wins nothing. The points budget is the author's contract: nothing +stops the sum of `points` exceeding `max_score`; audit your own game. +Note (v2 behavior change): `drop` and `consumes` now emit `inventory_changed` +— previously drop silently desynced client inventory displays. -**game.json** — `{"name":"...", "start_room":0, "intro_text":"...", "verbs":[{"name":"take","words":["yoink"]}], "flags":{"met_wizard":false}}` +**game.json** — `{"name":"...", "start_room":0, "intro_text":"...", "verbs":[{"name":"take","words":["yoink"]}], "flags":{"met_wizard":false}, "max_score":250}` (manifest verbs merge extra synonyms into the built-in table; the AI parser's -whitelist is generated from the same table so they can never drift.) +whitelist is generated from the same table so they can never drift. `max_score` +turns scoring on: the GUI shows the classic `Score: X of Y` and `score_changed` +events fire; 0 or absent = scoring off. Death emits `died` followed by the +usual `new_game` events.) ## The AI parser lane diff --git a/mrpgi-core/src/kit.rs b/mrpgi-core/src/kit.rs index a8e88d0..e7185b8 100644 --- a/mrpgi-core/src/kit.rs +++ b/mrpgi-core/src/kit.rs @@ -19,9 +19,7 @@ fn obj(name: &str, sprite: &str, x: i32, y: i32, look: &str, takeable: bool, nee use_text: use_text.into(), needs: needs.into(), wins, - requires_flag: String::new(), - sets_flag: String::new(), - dialogue: Vec::new(), + ..Default::default() } } @@ -72,6 +70,7 @@ pub fn write_sample_world(base: impl AsRef) { obj("sign", "sign", 78, 56, "A flickering sign: -> THIS WAY TO THE PARTY.", false, "", "", false, "arrow notice"), ], music: 4, + ..Default::default() }; // Room 1 — the Party Hall (WEST to 0, NORTH to 2) @@ -97,6 +96,7 @@ pub fn write_sample_world(base: impl AsRef) { 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, + ..Default::default() }; // Room 2 — the Exit Hatch (SOUTH back to 1) — the win room @@ -126,6 +126,7 @@ pub fn write_sample_world(base: impl AsRef) { "door gate exit", )], music: 2, + ..Default::default() }; for (i, room) in [room0, room1, room2].iter().enumerate() { @@ -194,6 +195,7 @@ pub fn write_kit(base: impl AsRef) { obj("sign", "sign", 80, 44, "A signpost: CAVE east, INN south.", false, "", "", false, "notice post"), ], music: 1, + ..Default::default() }; let mut s = walled(0, 8, 0, &[0, 3]); @@ -210,6 +212,7 @@ pub fn write_kit(base: impl AsRef) { 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, + ..Default::default() }; let mut s = walled(0, 8, 0, &[2]); @@ -223,6 +226,7 @@ pub fn write_kit(base: impl AsRef) { obj("torch", "torch", 30, 120, "A torch in a rusted bracket.", true, "", "", false, "light flame"), ], music: 3, + ..Default::default() }; let mut s = walled(6, 7, 6, &[0]); @@ -239,6 +243,7 @@ pub fn write_kit(base: impl AsRef) { obj("barrel", "barrel", 122, 120, "A barrel of inn ale. Smells like courage and mistakes.", false, "", "", false, "ale keg"), ], music: 4, + ..Default::default() }; let rooms: [(&str, &RoomDoc); 4] = [("forest", &forest), ("cave", &cave), ("dungeon", &dungeon), ("inn", &inn)]; diff --git a/mrpgi-core/src/parser.rs b/mrpgi-core/src/parser.rs index 06e7b3b..37db8dc 100644 --- a/mrpgi-core/src/parser.rs +++ b/mrpgi-core/src/parser.rs @@ -2,7 +2,7 @@ //! dialogue state. The AI lane plugs in *behind* this: it may only translate //! free text into a command this parser already accepts. -use crate::world::{DlgNode, ObjDef}; +use crate::world::{obj_visible, DlgNode, ObjDef}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -85,12 +85,25 @@ fn obj_matches(o: &ObjDef, noun: &str) -> bool { names.iter().any(|nm| nm.as_str() == noun || nm.contains(noun) || noun.contains(nm.as_str())) } -/// Index of a visible (not-taken) object that answers to `noun`. -fn match_obj(objects: &[ObjDef], room: u32, taken: &[(u32, String)], noun: &str) -> Option { - objects.iter().position(|o| { - let visible = !taken.iter().any(|(r, n)| *r == room && *n == o.name); - visible && obj_matches(o, noun) - }) +/// Index of a visible (not-taken, flag-visible) object that answers to `noun`. +fn match_obj( + objects: &[ObjDef], + room: u32, + taken: &[(u32, String)], + flags: &HashMap, + noun: &str, +) -> Option { + objects.iter().position(|o| obj_visible(o, room, taken, flags) && obj_matches(o, noun)) +} + +/// Award an object's points once per (room, name). Returns the feedback line. +fn award(o: &ObjDef, room: u32, score: &mut u32, scored: &mut Vec<(u32, String)>) -> Option { + if o.points == 0 || scored.iter().any(|(r, n)| *r == room && *n == o.name) { + return None; + } + scored.push((room, o.name.clone())); + *score += o.points; + Some(format!("(+{} points.)", o.points)) } fn flag_ok(flags: &HashMap, name: &str) -> bool { @@ -106,11 +119,15 @@ pub fn run_command( verbs: &[VerbDef], objects: &[ObjDef], room: u32, + room_name: &str, inventory: &mut Vec, taken: &mut Vec<(u32, String)>, won: &mut bool, flags: &mut HashMap, talk_target: &mut Option, + score: &mut u32, + scored: &mut Vec<(u32, String)>, + died: &mut bool, ) -> (Vec, bool) { let lower = input.trim().to_lowercase(); let words: Vec<&str> = lower.split_whitespace().filter(|w| !IGNORE.contains(w)).collect(); @@ -120,31 +137,34 @@ pub fn run_command( let verb = canon_verb(verbs, words[0]); let noun = words[1..].join(" "); let understood = !verb.is_empty(); + let title = if room_name.is_empty() { format!("Room {}", room) } else { room_name.to_string() }; let lines = match verb { "look" => { if noun.is_empty() || noun == "around" || noun == "room" || noun == "here" { let names: Vec = objects .iter() - .filter(|o| !taken.iter().any(|(r, n)| *r == room && *n == o.name)) + .filter(|o| obj_visible(o, room, taken, flags)) .map(|o| o.name.clone()) .collect(); if names.is_empty() { - vec![format!("Room {}. Nothing in particular stands out.", room)] + vec![format!("{}. Nothing in particular stands out.", title)] } else { - vec![format!("Room {}. You see: {}.", room, names.join(", "))] + vec![format!("{}. You see: {}.", title, names.join(", "))] } - } else if let Some(i) = match_obj(objects, room, taken, &noun) { + } else if let Some(i) = match_obj(objects, room, taken, flags, &noun) { vec![objects[i].look.clone()] } else { vec![format!("You don't see any '{}' here.", noun)] } } "take" => { - if let Some(i) = match_obj(objects, room, taken, &noun) { + if let Some(i) = match_obj(objects, room, taken, flags, &noun) { if objects[i].takeable { inventory.push(objects[i].name.clone()); taken.push((room, objects[i].name.clone())); - vec![format!("You take the {}.", objects[i].name)] + let mut out = vec![format!("You take the {}.", objects[i].name)]; + out.extend(award(&objects[i], room, score, scored)); + out } else { vec![format!("You can't take the {}.", objects[i].name)] } @@ -168,7 +188,7 @@ pub fn run_command( } } "use" => { - if let Some(i) = match_obj(objects, room, taken, &noun) { + if let Some(i) = match_obj(objects, room, taken, flags, &noun) { let needs = objects[i].needs.to_lowercase(); if !needs.is_empty() && !inventory.iter().any(|it| it.to_lowercase() == needs) { vec![format!("It's locked. You need: {}.", objects[i].needs)] @@ -183,9 +203,19 @@ pub fn run_command( if !objects[i].sets_flag.is_empty() { flags.insert(objects[i].sets_flag.clone(), true); } - if objects[i].wins { - *won = true; - out.push("\u{2605} THE END — you win! \u{2605}".to_string()); + if objects[i].consumes && !needs.is_empty() { + if let Some(p) = inventory.iter().position(|it| it.to_lowercase() == needs) { + inventory.remove(p); + } + } + if objects[i].kills { + *died = true; + } else { + out.extend(award(&objects[i], room, score, scored)); + if objects[i].wins { + *won = true; + out.push("\u{2605} THE END — you win! \u{2605}".to_string()); + } } out } @@ -194,7 +224,7 @@ pub fn run_command( } } "talk" => { - if let Some(i) = match_obj(objects, room, taken, &noun) { + if let Some(i) = match_obj(objects, room, taken, flags, &noun) { if objects[i].dialogue.is_empty() { vec![format!("{} has nothing to say.", objects[i].name)] } else { @@ -247,22 +277,43 @@ impl Dlg { self.visible(flags).is_empty() } - /// Pick visible choice `k` (1-based). Returns (lines to print, ended). - pub fn choose(&mut self, k: usize, flags: &mut HashMap) -> (Vec, bool) { + /// Pick visible choice `k` (1-based). + /// Returns (lines to print, ended, died). Choice points are awarded only + /// when `sets_flag` flips false → true — the flag is the once-only latch. + pub fn choose( + &mut self, + k: usize, + flags: &mut HashMap, + score: &mut u32, + ) -> (Vec, bool, bool) { let vis = self.visible(flags); if k == 0 || k > vis.len() { - return (vec![], false); + return (vec![], false, false); } let c = self.nodes[self.node].choices[vis[k - 1]].clone(); - if !c.sets_flag.is_empty() { + let mut out = vec![format!("> {}", c.text)]; + if c.kills { + // Death preempts flags and points (mirrors the object-use path). + // The goto node's says is the death text (no choices offered). + if c.goto >= 0 && (c.goto as usize) < self.nodes.len() { + out.push(self.nodes[c.goto as usize].says.clone()); + } + return (out, true, true); + } + if !c.sets_flag.is_empty() && !flags.get(&c.sets_flag).copied().unwrap_or(false) { + flags.insert(c.sets_flag.clone(), true); + if c.points > 0 { + *score += c.points; + out.push(format!("(+{} points.)", c.points)); + } + } else if !c.sets_flag.is_empty() { flags.insert(c.sets_flag.clone(), true); } - let mut out = vec![format!("> {}", c.text)]; if c.goto < 0 || c.goto as usize >= self.nodes.len() { - return (out, true); + return (out, true, false); } self.node = c.goto as usize; out.extend(self.lines(flags)); - (out, self.terminal(flags)) + (out, self.terminal(flags), false) } } diff --git a/mrpgi-core/src/state.rs b/mrpgi-core/src/state.rs index d5a9a2d..e0a7670 100644 --- a/mrpgi-core/src/state.rs +++ b/mrpgi-core/src/state.rs @@ -79,6 +79,10 @@ pub enum Event { DialogueOpen { lines: Vec }, DialogueClosed, Won, + /// The score changed (only emitted while max_score > 0). + ScoreChanged { score: u32, max: u32 }, + /// The player died; the day restarts (new_game events follow). + Died { message: String }, Audio { cue: AudioCue }, Music { mood: u8 }, EgoMoved { x: i32, y: i32, arrived: bool }, @@ -103,9 +107,12 @@ pub struct ObjSummary { pub struct StateSnapshot { pub game: String, pub room: u32, + pub room_name: String, pub ego: (i32, i32, u8), pub inventory: Vec, pub won: bool, + pub score: u32, + pub max_score: u32, pub flags: HashMap, pub vars: HashMap, pub exits: [Option; 4], @@ -122,6 +129,9 @@ pub struct GameState { pub inventory: Vec, pub taken: Vec<(u32, String)>, pub won: bool, + pub score: u32, + /// (room, object) pairs that have already paid out their points. + pub scored: Vec<(u32, String)>, pub dlg: Option, pub transcript: Vec, pub flags: HashMap, @@ -130,6 +140,9 @@ pub struct GameState { pub ai: ai::AiConfig, pending: Option>, acc: f32, + /// Per-direction "already told you it's blocked" latch (N,E,S,W); + /// re-arms when the ego leaves that edge's trigger zone. + blocked_latch: [bool; 4], } impl GameState { @@ -147,6 +160,8 @@ impl GameState { inventory: Vec::new(), taken: Vec::new(), won: false, + score: 0, + scored: Vec::new(), dlg: None, transcript: Vec::new(), flags, @@ -155,6 +170,7 @@ impl GameState { ai, pending: None, acc: 0.0, + blocked_latch: [false; 4], } } @@ -202,10 +218,19 @@ impl GameState { } Command::Choose { n } => { if let Some(mut d) = self.dlg.take() { - let (lines, ended) = d.choose(n, &mut self.flags); + let score_before = self.score; + let (lines, ended, died) = d.choose(n, &mut self.flags, &mut self.score); + if died { + ev.push(Event::DialogueClosed); + ev.extend(self.die(lines)); + return ev; + } for l in lines { ev.push(self.line(&l)); } + if self.score != score_before && self.world.manifest.max_score > 0 { + ev.push(Event::ScoreChanged { score: self.score, max: self.world.manifest.max_score }); + } if ended { ev.push(self.line("(the conversation ends.)")); ev.push(Event::DialogueClosed); @@ -234,6 +259,7 @@ impl GameState { Command::GotoRoom { n } => { self.world.goto_room(n); ev.extend(self.enter_room()); + ev.extend(self.room_announcement()); } Command::Query => { ev.push(Event::State { state: self.snapshot() }); @@ -377,6 +403,8 @@ impl GameState { self.inventory.clear(); self.taken.clear(); self.won = false; + self.score = 0; + self.scored.clear(); self.dlg = None; self.pending = None; self.transcript.clear(); @@ -384,8 +412,13 @@ impl GameState { self.vars.clear(); self.acc = 0.0; let mut ev = self.enter_room(); + if self.world.manifest.max_score > 0 { + // Fresh day, fresh zero — keep event-driven clients honest. + ev.push(Event::ScoreChanged { score: 0, max: self.world.manifest.max_score }); + } let intro = self.world.manifest.intro_text.clone(); ev.push(self.line(&intro)); + ev.extend(self.room_announcement()); ev } @@ -397,9 +430,39 @@ impl GameState { self.ego.dir = 0; self.ego.move_target = None; self.acc = 0.0; + self.blocked_latch = [false; 4]; vec![Event::RoomChanged { room: self.world.current, music: self.world.doc.music }] } + /// The current room's name + entry prose, as transcript events. + fn room_announcement(&mut self) -> Vec { + let name = self.world.doc.name.clone(); + let enter = self.world.doc.enter_text.clone(); + let mut ev = Vec::new(); + if !name.is_empty() { + ev.push(self.line(&format!("— {} —", name))); + } + if !enter.is_empty() { + ev.push(self.line(&enter)); + } + ev + } + + /// Sierra death: report it, restart the day, and keep the cause visible + /// at the top of the fresh transcript. + fn die(&mut self, cause_lines: Vec) -> Vec { + let mut ev = vec![ + Event::Died { message: cause_lines.last().cloned().unwrap_or_default() }, + Event::Audio { cue: AudioCue::Error }, + ]; + ev.extend(self.new_game(None)); + for l in &cause_lines { + ev.push(self.line(l)); + } + ev.push(self.line("\u{2020} You have died. Morp is not cruel; Morp is accurate. The day restarts. \u{2020}")); + ev + } + /// One fixed game cycle: move the ego, then handle walking off an edge /// with an exit. Paused entirely while a conversation is open. fn step_cycle(&mut self) -> Vec { @@ -411,6 +474,18 @@ impl GameState { let exits = self.world.doc.exits; let trig = 5; + // Re-arm blocked-exit messages once the ego leaves that trigger zone. + let in_zone = [ + self.ego.y <= 16 + trig, + self.ego.x >= PIC_W as i32 - 4 - trig, + self.ego.y >= PIC_H as i32 - 4 - trig, + self.ego.x <= 4 + trig, + ]; + for d in 0..4 { + if !in_zone[d] { + self.blocked_latch[d] = false; + } + } let leave = if self.ego.x <= 4 + trig && exits[3].is_some() { Some((exits[3].unwrap(), 3u8)) } else if self.ego.x >= PIC_W as i32 - 4 - trig && exits[1].is_some() { @@ -423,6 +498,41 @@ impl GameState { None }; if let Some((target, from)) = leave { + // A flag-gated exit refuses until its flag is set: the ego is + // nudged clear of EVERY trigger zone (so a corner never slingshots + // it through the perpendicular exit) and told why, once per try. + let req = self.world.doc.exit_flags[from as usize].clone(); + if !req.is_empty() && !self.flags.get(&req).copied().unwrap_or(false) { + let inset = trig + 3; + let (px, py) = (self.ego.x, self.ego.y); + let (mut nx, mut ny) = (px, py); + if nx <= 4 + trig && exits[3].is_some() { + nx = 4 + inset; + } else if nx >= PIC_W as i32 - 4 - trig && exits[1].is_some() { + nx = PIC_W as i32 - 4 - inset; + } + if ny <= 16 + trig && exits[0].is_some() { + ny = 16 + inset; + } else if ny >= PIC_H as i32 - 4 - trig && exits[2].is_some() { + ny = PIC_H as i32 - 4 - inset; + } + if self.world.fb.priority_at(nx, ny) != CTRL_BLOCK { + self.ego.x = nx; + self.ego.y = ny; + } // else: stay put — the latch below still stops repeats + self.ego.dir = 0; + self.ego.move_target = None; + // Latch per direction so a held key doesn't machine-gun the + // refusal; re-arms once the ego leaves the trigger zone. + if !self.blocked_latch[from as usize] { + self.blocked_latch[from as usize] = true; + let msg = self.world.doc.exit_blocked[from as usize].clone(); + let msg = if msg.is_empty() { "Something prevents you going that way.".to_string() } else { msg }; + ev.push(self.line(&msg)); + ev.push(Event::Audio { cue: AudioCue::Error }); + } + return ev; + } self.world.goto_room(target); let inset = 14; match from { @@ -439,8 +549,16 @@ impl GameState { self.ego.dir = 0; self.ego.move_target = None; self.acc = 0.0; - let msg = format!("You step into room {}.", self.world.current); + let msg = if self.world.doc.name.is_empty() { + format!("You step into room {}.", self.world.current) + } else { + format!("You step into {}.", self.world.doc.name) + }; ev.push(self.line(&msg)); + let enter = self.world.doc.enter_text.clone(); + if !enter.is_empty() { + ev.push(self.line(&enter)); + } ev.push(Event::Audio { cue: AudioCue::Door }); ev.push(Event::RoomChanged { room: self.world.current, music: self.world.doc.music }); } @@ -455,19 +573,30 @@ impl GameState { ev.push(self.line(&echo)); let won_before = self.won; - let inv_before = self.inventory.len(); + let inv_before = self.inventory.clone(); + let score_before = self.score; let mut talk_target: Option = None; + let mut died = false; + let room_name = self.world.doc.name.clone(); let (lines, understood) = parser::run_command( line, &self.verbs, &self.world.doc.objects, self.world.current, + &room_name, &mut self.inventory, &mut self.taken, &mut self.won, &mut self.flags, &mut talk_target, + &mut self.score, + &mut self.scored, + &mut died, ); + if died { + ev.extend(self.die(lines)); + return ev; + } if let Some(i) = talk_target { let nodes = self.world.doc.objects[i].dialogue.clone(); @@ -493,7 +622,7 @@ impl GameState { .doc .objects .iter() - .filter(|o| !self.taken.iter().any(|(r, n)| *r == room && *n == o.name)) + .filter(|o| crate::world::obj_visible(o, room, &self.taken, &self.flags)) .map(|o| o.name.clone()) .collect(); ev.push(self.line("\u{2026}(thinking)\u{2026}")); @@ -506,12 +635,19 @@ impl GameState { for l in &lines { ev.push(self.line(l)); } + if self.score != score_before && self.world.manifest.max_score > 0 { + ev.push(Event::ScoreChanged { score: self.score, max: self.world.manifest.max_score }); + } if self.won && !won_before { ev.push(Event::Won); ev.push(Event::Audio { cue: AudioCue::Win }); - } else if self.inventory.len() > inv_before { + } else if self.inventory != inv_before { ev.push(Event::InventoryChanged { items: self.inventory.clone() }); - ev.push(Event::Audio { cue: AudioCue::Pickup }); + if self.inventory.len() > inv_before.len() { + ev.push(Event::Audio { cue: AudioCue::Pickup }); + } else { + ev.push(Event::Audio { cue: AudioCue::Confirm }); + } } else if understood || from_ai { ev.push(Event::Audio { cue: AudioCue::Confirm }); } else { @@ -542,9 +678,12 @@ impl GameState { StateSnapshot { game: self.world.manifest.name.clone(), room, + room_name: self.world.doc.name.clone(), ego: (self.ego.x, self.ego.y, self.ego.dir), inventory: self.inventory.clone(), won: self.won, + score: self.score, + max_score: self.world.manifest.max_score, flags: self.flags.clone(), vars: self.vars.clone(), exits: self.world.doc.exits, @@ -554,7 +693,7 @@ impl GameState { .doc .objects .iter() - .filter(|o| !self.taken.iter().any(|(r, n)| *r == room && *n == o.name)) + .filter(|o| crate::world::obj_visible(o, room, &self.taken, &self.flags)) .map(|o| ObjSummary { name: o.name.clone(), x: o.x, @@ -569,12 +708,12 @@ impl GameState { } } - /// The props visible right now (room objects minus anything taken). + /// The props visible right now (room objects minus taken/flag-hidden ones). pub fn props(&self) -> Vec { let room = self.world.current; let mut props = Vec::new(); for o in &self.world.doc.objects { - if self.taken.iter().any(|(r, n)| *r == room && n == &o.name) { + if !crate::world::obj_visible(o, room, &self.taken, &self.flags) { continue; } if let Some(cel) = self.world.sprite_cel(&o.sprite) { diff --git a/mrpgi-core/src/world.rs b/mrpgi-core/src/world.rs index e8cabb6..46b24f6 100644 --- a/mrpgi-core/src/world.rs +++ b/mrpgi-core/src/world.rs @@ -54,7 +54,7 @@ pub enum Stroke { Image { name: String, x: i32, y: i32, meaning: Meaning }, } -#[derive(Clone, Serialize, Deserialize)] +#[derive(Clone, Default, Serialize, Deserialize)] pub struct DlgChoice { pub text: String, pub goto: i32, // next node index, or -1 to end the conversation @@ -64,6 +64,14 @@ pub struct DlgChoice { /// 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)] @@ -75,7 +83,7 @@ pub struct DlgNode { /// 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, Serialize, Deserialize)] +#[derive(Clone, Default, Serialize, Deserialize)] pub struct ObjDef { pub name: String, pub sprite: String, @@ -101,6 +109,35 @@ pub struct ObjDef { pub sets_flag: String, #[serde(default)] pub dialogue: Vec, // 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, +) -> 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)] @@ -113,11 +150,37 @@ pub struct RoomDoc { pub objects: Vec, #[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 } + 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(), + } } } @@ -137,6 +200,9 @@ pub struct GameManifest { /// Initial flag values for a new game. #[serde(default)] pub flags: HashMap, + /// Total points on offer; 0 = scoring off (no HUD line, no score events). + #[serde(default)] + pub max_score: u32, } fn default_name() -> String { @@ -154,6 +220,7 @@ impl Default for GameManifest { intro_text: default_intro(), verbs: Vec::new(), flags: HashMap::new(), + max_score: 0, } } } diff --git a/mrpgi-core/tests/engine.rs b/mrpgi-core/tests/engine.rs index a48873e..0374174 100644 --- a/mrpgi-core/tests/engine.rs +++ b/mrpgi-core/tests/engine.rs @@ -16,24 +16,41 @@ fn obj(name: &str, takeable: bool, needs: &str, use_text: &str, wins: bool) -> O y: 100, look: format!("It's a {}.", name), takeable, - synonyms: String::new(), use_text: use_text.into(), needs: needs.into(), wins, - requires_flag: String::new(), - sets_flag: String::new(), - dialogue: Vec::new(), + ..Default::default() + } +} + +/// A bag of the parser's mutable state, so tests read like scripts. +#[derive(Default)] +struct Sim { + inv: Vec, + taken: Vec<(u32, String)>, + won: bool, + flags: HashMap, + score: u32, + scored: Vec<(u32, String)>, + died: bool, +} + +impl Sim { + fn run(&mut self, input: &str, objects: &[ObjDef]) -> (Vec, bool) { + let verbs = default_verbs(); + let mut talk = None; + run_command( + input, &verbs, objects, 0, "", &mut self.inv, &mut self.taken, &mut self.won, + &mut self.flags, &mut talk, &mut self.score, &mut self.scored, &mut self.died, + ) } } /// Run one line against a tiny fixture room; return (first line, understood). fn run(input: &str, objects: &[ObjDef], inventory: &mut Vec) -> (String, bool) { - let verbs = default_verbs(); - let mut taken = Vec::new(); - let mut won = false; - let mut flags = HashMap::new(); - let mut talk = None; - let (lines, understood) = run_command(input, &verbs, objects, 0, inventory, &mut taken, &mut won, &mut flags, &mut talk); + let mut sim = Sim { inv: std::mem::take(inventory), ..Default::default() }; + let (lines, understood) = sim.run(input, objects); + *inventory = sim.inv; (lines.first().cloned().unwrap_or_default(), understood) } @@ -57,21 +74,16 @@ fn parser_goldens() { #[test] fn use_respects_needs_lock() { let objects = vec![obj("battery", true, "", "", false), obj("hatch", false, "battery", "It opens!", true)]; - let verbs = default_verbs(); - let mut inv = Vec::new(); - let mut taken = Vec::new(); - let mut won = false; - let mut flags = HashMap::new(); - let mut talk = None; + let mut sim = Sim::default(); - let (lines, _) = run_command("open hatch", &verbs, &objects, 0, &mut inv, &mut taken, &mut won, &mut flags, &mut talk); + let (lines, _) = sim.run("open hatch", &objects); assert_eq!(lines[0], "It's locked. You need: battery."); - assert!(!won); + assert!(!sim.won); - run_command("take battery", &verbs, &objects, 0, &mut inv, &mut taken, &mut won, &mut flags, &mut talk); - let (lines, _) = run_command("open hatch", &verbs, &objects, 0, &mut inv, &mut taken, &mut won, &mut flags, &mut talk); + sim.run("take battery", &objects); + let (lines, _) = sim.run("open hatch", &objects); assert_eq!(lines[0], "It opens!"); - assert!(won); + assert!(sim.won); } #[test] @@ -81,20 +93,18 @@ fn use_respects_flag_gate_and_sets_flags() { let mut door = obj("door", false, "", "The door slides open.", true); door.requires_flag = "power_on".into(); let objects = vec![lever, door]; + let mut sim = Sim::default(); - let verbs = default_verbs(); - let (mut inv, mut taken, mut won, mut flags, mut talk) = (Vec::new(), Vec::new(), false, HashMap::new(), None); - - let (lines, _) = run_command("use door", &verbs, &objects, 0, &mut inv, &mut taken, &mut won, &mut flags, &mut talk); + let (lines, _) = sim.run("use door", &objects); assert_eq!(lines[0], "Nothing happens. Something else must come first."); - assert!(!won); + assert!(!sim.won); - run_command("use lever", &verbs, &objects, 0, &mut inv, &mut taken, &mut won, &mut flags, &mut talk); - assert_eq!(flags.get("power_on"), Some(&true)); + sim.run("use lever", &objects); + assert_eq!(sim.flags.get("power_on"), Some(&true)); - let (lines, _) = run_command("use door", &verbs, &objects, 0, &mut inv, &mut taken, &mut won, &mut flags, &mut talk); + let (lines, _) = sim.run("use door", &objects); assert_eq!(lines[0], "The door slides open."); - assert!(won); + assert!(sim.won); } #[test] @@ -102,12 +112,69 @@ fn manifest_verbs_extend_the_table() { let extra = vec![mrpgi_core::parser::VerbDef { name: "take".into(), words: vec!["yoink".into()] }]; let verbs = build_verbs(&extra); let objects = vec![obj("battery", true, "", "", false)]; - let (mut inv, mut taken, mut won, mut flags, mut talk) = (Vec::new(), Vec::new(), false, HashMap::new(), None); - let (lines, understood) = run_command("yoink battery", &verbs, &objects, 0, &mut inv, &mut taken, &mut won, &mut flags, &mut talk); + let mut sim = Sim::default(); + let mut talk = None; + let (lines, understood) = run_command( + "yoink battery", &verbs, &objects, 0, "", &mut sim.inv, &mut sim.taken, &mut sim.won, + &mut sim.flags, &mut talk, &mut sim.score, &mut sim.scored, &mut sim.died, + ); assert!(understood); assert_eq!(lines[0], "You take the battery."); } +#[test] +fn points_award_once_and_consumes_eats_the_item() { + let mut coin = obj("coin", true, "", "", false); + coin.points = 5; + let mut slot = obj("slot", false, "coin", "Clunk. The mechanism accepts your offering.", false); + slot.points = 10; + slot.consumes = true; + let objects = vec![coin, slot]; + let mut sim = Sim::default(); + + let (lines, _) = sim.run("take coin", &objects); + assert_eq!(lines, vec!["You take the coin.".to_string(), "(+5 points.)".to_string()]); + assert_eq!(sim.score, 5); + + let (lines, _) = sim.run("use slot", &objects); + assert_eq!(lines[1], "(+10 points.)"); + assert_eq!(sim.score, 15); + assert!(sim.inv.is_empty(), "consumes must remove the coin"); + + // no re-award, and the coin is gone so the lock re-engages + let (lines, _) = sim.run("use slot", &objects); + assert_eq!(lines[0], "It's locked. You need: coin."); + assert_eq!(sim.score, 15); +} + +#[test] +fn kills_sets_died_and_visibility_flags_hide_objects() { + let mut kebab = obj("kebab", false, "", "You eat it. It eats back.", false); + kebab.kills = true; + let mut ghost = obj("ghost", false, "", "", false); + ghost.visible_flag = "seance".into(); + let mut queue = obj("queue", false, "", "", false); + queue.hidden_by_flag = "dispersed".into(); + let objects = vec![kebab, ghost, queue]; + let mut sim = Sim::default(); + + let (lines, _) = sim.run("look ghost", &objects); + assert!(lines[0].starts_with("You don't see any"), "invisible until its flag: {:?}", lines); + let (lines, _) = sim.run("look queue", &objects); + assert!(lines[0].starts_with("It's a queue")); + sim.flags.insert("seance".into(), true); + sim.flags.insert("dispersed".into(), true); + let (lines, _) = sim.run("look ghost", &objects); + assert!(lines[0].starts_with("It's a ghost")); + let (lines, _) = sim.run("look queue", &objects); + assert!(lines[0].starts_with("You don't see any"), "hidden once its flag set: {:?}", lines); + + let (lines, _) = sim.run("use kebab", &objects); + assert_eq!(lines[0], "You eat it. It eats back."); + assert!(sim.died); + assert!(!sim.won); +} + /// Play the shipped sample game start → win, entirely over the bus, twice — /// and require the two transcripts to be byte-identical (replay determinism). #[test] @@ -148,6 +215,120 @@ fn sample_game_win_path_is_deterministic() { std::fs::remove_dir_all(&dir).ok(); } +/// Flag-gated exits refuse until the flag is set; death restarts the day; +/// score events fire only when max_score is declared. +#[test] +fn gated_exits_death_and_score_over_the_bus() { + let dir = std::env::temp_dir().join(format!("mrpgi-gate-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + mrpgi_core::kit::write_sample_world(&dir); + + // Gate room 0's east exit behind `belt-off`, name the room, add a killer + // and a scoring object, and declare a max score. + let mut doc: mrpgi_core::RoomDoc = + serde_json::from_str(&std::fs::read_to_string(dir.join("rooms/room0.json")).unwrap()).unwrap(); + doc.name = "The Boot-Up Closet".into(); + doc.enter_text = "It smells like warm solder and second chances.".into(); + doc.exit_flags[1] = "belt-off".into(); + doc.exit_blocked[1] = "The seatbelt of destiny holds you fast.".into(); + let mut belt = obj("belt", false, "", "Click. You are free.", false); + belt.sets_flag = "belt-off".into(); + belt.points = 7; + doc.objects.push(belt); + let mut pill = obj("pill", false, "", "You swallow the mystery pill.", false); + pill.kills = true; + doc.objects.push(pill); + std::fs::write(dir.join("rooms/room0.json"), serde_json::to_string(&doc).unwrap()).unwrap(); + let manifest = r#"{"name":"gate-test","max_score":7,"intro_text":"Go."}"#; + std::fs::write(dir.join("game.json"), manifest).unwrap(); + + let mut gs = GameState::new(World::load_game(&dir), AiConfig::off()); + let ev = gs.apply(Command::NewGame { room: None }); + assert!(gs.transcript.iter().any(|l| l.contains("Boot-Up Closet")), "room name announced: {:?}", gs.transcript); + drop(ev); + + // Blocked at the east edge until the belt flag is set. + gs.apply(Command::WalkTo { x: 156, y: 85 }); + assert_eq!(gs.world.current, 0, "gated exit must hold"); + assert!(gs.transcript.iter().any(|l| l.contains("seatbelt of destiny")), "{:?}", gs.transcript); + + // Free the belt (+7 points, ScoreChanged), then the same walk passes. + let ev = gs.apply(Command::Parse { text: "use belt".into() }); + assert!(ev.iter().any(|e| matches!(e, Event::ScoreChanged { score: 7, max: 7 }))); + gs.apply(Command::WalkTo { x: 156, y: 85 }); + gs.apply(Command::Tick { n: 10 }); + assert_eq!(gs.world.current, 1, "exit must open once flagged"); + + // Death: back to room 0, swallow the pill, expect Died + restart. + gs.apply(Command::GotoRoom { n: 0 }); + let ev = gs.apply(Command::Parse { text: "use pill".into() }); + assert!(ev.iter().any(|e| matches!(e, Event::Died { .. }))); + assert_eq!(gs.world.current, 0); + assert_eq!(gs.score, 0, "death restarts the day"); + assert!(gs.transcript.iter().any(|l| l.contains("You have died")), "{:?}", gs.transcript); + + std::fs::remove_dir_all(&dir).ok(); +} + +/// Review regressions: a corner block must not slingshot the ego through the +/// perpendicular open exit; held-key retries must not repeat the message; a +/// killing choice must pay no points; death/new-game must re-zero the score +/// on the event bus. +#[test] +fn blocked_corner_does_not_slingshot_and_kill_choices_pay_nothing() { + let dir = std::env::temp_dir().join(format!("mrpgi-corner-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + mrpgi_core::kit::write_sample_world(&dir); + + // Room 0: WEST exit gated (flag never set), NORTH exit open, both to room 1. + let mut doc: mrpgi_core::RoomDoc = + serde_json::from_str(&std::fs::read_to_string(dir.join("rooms/room0.json")).unwrap()).unwrap(); + doc.strokes.clear(); // fully walkable: the corner must be reachable + doc.exits = [Some(1), None, None, Some(1)]; + doc.exit_flags[3] = "never-set".into(); + // A talker with a scoring choice and a killing choice. + let mut npc = obj("cultist", false, "", "", false); + npc.dialogue = vec![ + mrpgi_core::world::DlgNode { + says: "Join us?".into(), + choices: vec![ + mrpgi_core::world::DlgChoice { + text: "Yes (die)".into(), goto: 1, kills: true, points: 50, + sets_flag: "joined".into(), ..Default::default() + }, + ], + }, + mrpgi_core::world::DlgNode { says: "The robes were a mistake.".into(), choices: vec![] }, + ]; + doc.objects.push(npc); + std::fs::write(dir.join("rooms/room0.json"), serde_json::to_string(&doc).unwrap()).unwrap(); + std::fs::write(dir.join("game.json"), r#"{"name":"corner-test","max_score":50,"intro_text":"Go."}"#).unwrap(); + + let mut gs = GameState::new(World::load_game(&dir), AiConfig::off()); + gs.apply(Command::NewGame { room: None }); + + // Walk into the NW corner: west gate blocks; the open north exit must NOT fire. + gs.apply(Command::WalkTo { x: 6, y: 18 }); + let before = gs.transcript.len(); + gs.apply(Command::Tick { n: 30 }); + assert_eq!(gs.world.current, 0, "must not slingshot through the north exit"); + assert_eq!(gs.transcript.len(), before, "no repeated blocked messages while idle"); + let blocks = gs.transcript.iter().filter(|l| l.contains("Something prevents")).count(); + assert_eq!(blocks, 1, "the refusal prints once: {:?}", gs.transcript); + + // Kill choice: no points, no flag, score still zero after restart. + gs.apply(Command::Parse { text: "talk cultist".into() }); + let ev = gs.apply(Command::Choose { n: 1 }); + assert!(ev.iter().any(|e| matches!(e, Event::Died { .. }))); + assert!(ev.iter().any(|e| matches!(e, Event::ScoreChanged { score: 0, .. })), "restart re-zeroes the score on the bus"); + assert_eq!(gs.score, 0); + assert_eq!(gs.flags.get("joined"), None, "kills preempts sets_flag"); + assert!(!gs.transcript.iter().any(|l| l.contains("points")), "kills pays nothing: {:?}", gs.transcript); + assert!(gs.transcript.iter().any(|l| l.contains("robes were a mistake")), "death text from goto node"); + + std::fs::remove_dir_all(&dir).ok(); +} + /// Headless render smoke test: the frame is a real PNG with the right size. #[test] fn renders_a_png_with_no_window() { diff --git a/mrpgi/src/editor.rs b/mrpgi/src/editor.rs index b448c82..c21f50f 100644 --- a/mrpgi/src/editor.rs +++ b/mrpgi/src/editor.rs @@ -390,13 +390,7 @@ impl Editor { y: cpy, look, takeable: true, - synonyms: String::new(), - use_text: String::new(), - needs: String::new(), - wins: false, - requires_flag: String::new(), - sets_flag: String::new(), - dialogue: Vec::new(), + ..Default::default() }); self.selected_obj = Some(gs.world.doc.objects.len() - 1); self.status = "placed object — Tab to play, then 'look '".into(); diff --git a/mrpgi/src/main.rs b/mrpgi/src/main.rs index b3138d9..cb0fdc9 100644 --- a/mrpgi/src/main.rs +++ b/mrpgi/src/main.rs @@ -440,6 +440,12 @@ async fn main() { draw_scanlines(&layout); } draw_menus(&menus, menu_open, mmx, mmy); + if gs.world.manifest.max_score > 0 { + // The classic Sierra score readout, right end of the bar. + let t = format!("Score: {} of {}", gs.score, gs.world.manifest.max_score); + let dim = measure_text(&t, None, 16, 1.0); + draw_text(&t, screen_width() - dim.width - 14.0, 16.0, 16.0, MENU_DARK); + } draw_play_panel(&gs, &command, crt, &ai_label, !audio.muted); if gs.won { let sw2 = screen_width();