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>
This commit is contained in:
type-two 2026-07-05 15:30:32 +10:00
parent eb4a21491e
commit 755168a969
8 changed files with 543 additions and 82 deletions

View File

@ -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}` **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) (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): **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`, (space-separated), `use_text`, `needs` (item that unlocks it), `wins`,
`requires_flag` / `sets_flag` (puzzle gating), `dialogue` (nodes of `requires_flag` / `sets_flag` (puzzle gating), `dialogue` (nodes of
`{"says":"...","choices":[{"text":"...","goto":1,"requires_flag":"","sets_flag":""}]}`, `{"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 (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 ## The AI parser lane

View File

@ -19,9 +19,7 @@ fn obj(name: &str, sprite: &str, x: i32, y: i32, look: &str, takeable: bool, nee
use_text: use_text.into(), use_text: use_text.into(),
needs: needs.into(), needs: needs.into(),
wins, wins,
requires_flag: String::new(), ..Default::default()
sets_flag: String::new(),
dialogue: Vec::new(),
} }
} }
@ -72,6 +70,7 @@ pub fn write_sample_world(base: impl AsRef<Path>) {
obj("sign", "sign", 78, 56, "A flickering sign: -> THIS WAY TO THE PARTY.", false, "", "", false, "arrow notice"), obj("sign", "sign", 78, 56, "A flickering sign: -> THIS WAY TO THE PARTY.", false, "", "", false, "arrow notice"),
], ],
music: 4, music: 4,
..Default::default()
}; };
// Room 1 — the Party Hall (WEST to 0, NORTH to 2) // Room 1 — the Party Hall (WEST to 0, NORTH to 2)
@ -97,6 +96,7 @@ pub fn write_sample_world(base: impl AsRef<Path>) {
obj("punch", "barrel", 30, 142, "A barrel of suspicious party punch. You have no mouth, but you respect the commitment.", false, "", "", false, "drink barrel"), 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, music: 4,
..Default::default()
}; };
// Room 2 — the Exit Hatch (SOUTH back to 1) — the win room // Room 2 — the Exit Hatch (SOUTH back to 1) — the win room
@ -126,6 +126,7 @@ pub fn write_sample_world(base: impl AsRef<Path>) {
"door gate exit", "door gate exit",
)], )],
music: 2, music: 2,
..Default::default()
}; };
for (i, room) in [room0, room1, room2].iter().enumerate() { for (i, room) in [room0, room1, room2].iter().enumerate() {
@ -194,6 +195,7 @@ pub fn write_kit(base: impl AsRef<Path>) {
obj("sign", "sign", 80, 44, "A signpost: CAVE east, INN south.", false, "", "", false, "notice post"), obj("sign", "sign", 80, 44, "A signpost: CAVE east, INN south.", false, "", "", false, "notice post"),
], ],
music: 1, music: 1,
..Default::default()
}; };
let mut s = walled(0, 8, 0, &[0, 3]); let mut s = walled(0, 8, 0, &[0, 3]);
@ -210,6 +212,7 @@ pub fn write_kit(base: impl AsRef<Path>) {
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"), 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, music: 2,
..Default::default()
}; };
let mut s = walled(0, 8, 0, &[2]); let mut s = walled(0, 8, 0, &[2]);
@ -223,6 +226,7 @@ pub fn write_kit(base: impl AsRef<Path>) {
obj("torch", "torch", 30, 120, "A torch in a rusted bracket.", true, "", "", false, "light flame"), obj("torch", "torch", 30, 120, "A torch in a rusted bracket.", true, "", "", false, "light flame"),
], ],
music: 3, music: 3,
..Default::default()
}; };
let mut s = walled(6, 7, 6, &[0]); let mut s = walled(6, 7, 6, &[0]);
@ -239,6 +243,7 @@ pub fn write_kit(base: impl AsRef<Path>) {
obj("barrel", "barrel", 122, 120, "A barrel of inn ale. Smells like courage and mistakes.", false, "", "", false, "ale keg"), obj("barrel", "barrel", 122, 120, "A barrel of inn ale. Smells like courage and mistakes.", false, "", "", false, "ale keg"),
], ],
music: 4, music: 4,
..Default::default()
}; };
let rooms: [(&str, &RoomDoc); 4] = [("forest", &forest), ("cave", &cave), ("dungeon", &dungeon), ("inn", &inn)]; let rooms: [(&str, &RoomDoc); 4] = [("forest", &forest), ("cave", &cave), ("dungeon", &dungeon), ("inn", &inn)];

View File

@ -2,7 +2,7 @@
//! dialogue state. The AI lane plugs in *behind* this: it may only translate //! dialogue state. The AI lane plugs in *behind* this: it may only translate
//! free text into a command this parser already accepts. //! 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 serde::{Deserialize, Serialize};
use std::collections::HashMap; 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())) 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`. /// Index of a visible (not-taken, flag-visible) object that answers to `noun`.
fn match_obj(objects: &[ObjDef], room: u32, taken: &[(u32, String)], noun: &str) -> Option<usize> { fn match_obj(
objects.iter().position(|o| { objects: &[ObjDef],
let visible = !taken.iter().any(|(r, n)| *r == room && *n == o.name); room: u32,
visible && obj_matches(o, noun) taken: &[(u32, String)],
}) flags: &HashMap<String, bool>,
noun: &str,
) -> Option<usize> {
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<String> {
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<String, bool>, name: &str) -> bool { fn flag_ok(flags: &HashMap<String, bool>, name: &str) -> bool {
@ -106,11 +119,15 @@ pub fn run_command(
verbs: &[VerbDef], verbs: &[VerbDef],
objects: &[ObjDef], objects: &[ObjDef],
room: u32, room: u32,
room_name: &str,
inventory: &mut Vec<String>, inventory: &mut Vec<String>,
taken: &mut Vec<(u32, String)>, taken: &mut Vec<(u32, String)>,
won: &mut bool, won: &mut bool,
flags: &mut HashMap<String, bool>, flags: &mut HashMap<String, bool>,
talk_target: &mut Option<usize>, talk_target: &mut Option<usize>,
score: &mut u32,
scored: &mut Vec<(u32, String)>,
died: &mut bool,
) -> (Vec<String>, bool) { ) -> (Vec<String>, bool) {
let lower = input.trim().to_lowercase(); let lower = input.trim().to_lowercase();
let words: Vec<&str> = lower.split_whitespace().filter(|w| !IGNORE.contains(w)).collect(); 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 verb = canon_verb(verbs, words[0]);
let noun = words[1..].join(" "); let noun = words[1..].join(" ");
let understood = !verb.is_empty(); let understood = !verb.is_empty();
let title = if room_name.is_empty() { format!("Room {}", room) } else { room_name.to_string() };
let lines = match verb { let lines = match verb {
"look" => { "look" => {
if noun.is_empty() || noun == "around" || noun == "room" || noun == "here" { if noun.is_empty() || noun == "around" || noun == "room" || noun == "here" {
let names: Vec<String> = objects let names: Vec<String> = objects
.iter() .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()) .map(|o| o.name.clone())
.collect(); .collect();
if names.is_empty() { if names.is_empty() {
vec![format!("Room {}. Nothing in particular stands out.", room)] vec![format!("{}. Nothing in particular stands out.", title)]
} else { } 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()] vec![objects[i].look.clone()]
} else { } else {
vec![format!("You don't see any '{}' here.", noun)] vec![format!("You don't see any '{}' here.", noun)]
} }
} }
"take" => { "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 { if objects[i].takeable {
inventory.push(objects[i].name.clone()); inventory.push(objects[i].name.clone());
taken.push((room, 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 { } else {
vec![format!("You can't take the {}.", objects[i].name)] vec![format!("You can't take the {}.", objects[i].name)]
} }
@ -168,7 +188,7 @@ pub fn run_command(
} }
} }
"use" => { "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(); let needs = objects[i].needs.to_lowercase();
if !needs.is_empty() && !inventory.iter().any(|it| it.to_lowercase() == needs) { if !needs.is_empty() && !inventory.iter().any(|it| it.to_lowercase() == needs) {
vec![format!("It's locked. You need: {}.", objects[i].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() { if !objects[i].sets_flag.is_empty() {
flags.insert(objects[i].sets_flag.clone(), true); flags.insert(objects[i].sets_flag.clone(), true);
} }
if objects[i].wins { if objects[i].consumes && !needs.is_empty() {
*won = true; if let Some(p) = inventory.iter().position(|it| it.to_lowercase() == needs) {
out.push("\u{2605} THE END — you win! \u{2605}".to_string()); 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 out
} }
@ -194,7 +224,7 @@ pub fn run_command(
} }
} }
"talk" => { "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() { if objects[i].dialogue.is_empty() {
vec![format!("{} has nothing to say.", objects[i].name)] vec![format!("{} has nothing to say.", objects[i].name)]
} else { } else {
@ -247,22 +277,43 @@ impl Dlg {
self.visible(flags).is_empty() self.visible(flags).is_empty()
} }
/// Pick visible choice `k` (1-based). Returns (lines to print, ended). /// Pick visible choice `k` (1-based).
pub fn choose(&mut self, k: usize, flags: &mut HashMap<String, bool>) -> (Vec<String>, bool) { /// 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<String, bool>,
score: &mut u32,
) -> (Vec<String>, bool, bool) {
let vis = self.visible(flags); let vis = self.visible(flags);
if k == 0 || k > vis.len() { if k == 0 || k > vis.len() {
return (vec![], false); return (vec![], false, false);
} }
let c = self.nodes[self.node].choices[vis[k - 1]].clone(); 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); 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() { if c.goto < 0 || c.goto as usize >= self.nodes.len() {
return (out, true); return (out, true, false);
} }
self.node = c.goto as usize; self.node = c.goto as usize;
out.extend(self.lines(flags)); out.extend(self.lines(flags));
(out, self.terminal(flags)) (out, self.terminal(flags), false)
} }
} }

View File

@ -79,6 +79,10 @@ pub enum Event {
DialogueOpen { lines: Vec<String> }, DialogueOpen { lines: Vec<String> },
DialogueClosed, DialogueClosed,
Won, 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 }, Audio { cue: AudioCue },
Music { mood: u8 }, Music { mood: u8 },
EgoMoved { x: i32, y: i32, arrived: bool }, EgoMoved { x: i32, y: i32, arrived: bool },
@ -103,9 +107,12 @@ pub struct ObjSummary {
pub struct StateSnapshot { pub struct StateSnapshot {
pub game: String, pub game: String,
pub room: u32, pub room: u32,
pub room_name: String,
pub ego: (i32, i32, u8), pub ego: (i32, i32, u8),
pub inventory: Vec<String>, pub inventory: Vec<String>,
pub won: bool, pub won: bool,
pub score: u32,
pub max_score: u32,
pub flags: HashMap<String, bool>, pub flags: HashMap<String, bool>,
pub vars: HashMap<String, i32>, pub vars: HashMap<String, i32>,
pub exits: [Option<u32>; 4], pub exits: [Option<u32>; 4],
@ -122,6 +129,9 @@ pub struct GameState {
pub inventory: Vec<String>, pub inventory: Vec<String>,
pub taken: Vec<(u32, String)>, pub taken: Vec<(u32, String)>,
pub won: bool, pub won: bool,
pub score: u32,
/// (room, object) pairs that have already paid out their points.
pub scored: Vec<(u32, String)>,
pub dlg: Option<Dlg>, pub dlg: Option<Dlg>,
pub transcript: Vec<String>, pub transcript: Vec<String>,
pub flags: HashMap<String, bool>, pub flags: HashMap<String, bool>,
@ -130,6 +140,9 @@ pub struct GameState {
pub ai: ai::AiConfig, pub ai: ai::AiConfig,
pending: Option<std::sync::mpsc::Receiver<String>>, pending: Option<std::sync::mpsc::Receiver<String>>,
acc: f32, 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 { impl GameState {
@ -147,6 +160,8 @@ impl GameState {
inventory: Vec::new(), inventory: Vec::new(),
taken: Vec::new(), taken: Vec::new(),
won: false, won: false,
score: 0,
scored: Vec::new(),
dlg: None, dlg: None,
transcript: Vec::new(), transcript: Vec::new(),
flags, flags,
@ -155,6 +170,7 @@ impl GameState {
ai, ai,
pending: None, pending: None,
acc: 0.0, acc: 0.0,
blocked_latch: [false; 4],
} }
} }
@ -202,10 +218,19 @@ impl GameState {
} }
Command::Choose { n } => { Command::Choose { n } => {
if let Some(mut d) = self.dlg.take() { 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 { for l in lines {
ev.push(self.line(&l)); 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 { if ended {
ev.push(self.line("(the conversation ends.)")); ev.push(self.line("(the conversation ends.)"));
ev.push(Event::DialogueClosed); ev.push(Event::DialogueClosed);
@ -234,6 +259,7 @@ impl GameState {
Command::GotoRoom { n } => { Command::GotoRoom { n } => {
self.world.goto_room(n); self.world.goto_room(n);
ev.extend(self.enter_room()); ev.extend(self.enter_room());
ev.extend(self.room_announcement());
} }
Command::Query => { Command::Query => {
ev.push(Event::State { state: self.snapshot() }); ev.push(Event::State { state: self.snapshot() });
@ -377,6 +403,8 @@ impl GameState {
self.inventory.clear(); self.inventory.clear();
self.taken.clear(); self.taken.clear();
self.won = false; self.won = false;
self.score = 0;
self.scored.clear();
self.dlg = None; self.dlg = None;
self.pending = None; self.pending = None;
self.transcript.clear(); self.transcript.clear();
@ -384,8 +412,13 @@ impl GameState {
self.vars.clear(); self.vars.clear();
self.acc = 0.0; self.acc = 0.0;
let mut ev = self.enter_room(); 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(); let intro = self.world.manifest.intro_text.clone();
ev.push(self.line(&intro)); ev.push(self.line(&intro));
ev.extend(self.room_announcement());
ev ev
} }
@ -397,9 +430,39 @@ impl GameState {
self.ego.dir = 0; self.ego.dir = 0;
self.ego.move_target = None; self.ego.move_target = None;
self.acc = 0.0; self.acc = 0.0;
self.blocked_latch = [false; 4];
vec![Event::RoomChanged { room: self.world.current, music: self.world.doc.music }] 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<Event> {
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<String>) -> Vec<Event> {
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 /// One fixed game cycle: move the ego, then handle walking off an edge
/// with an exit. Paused entirely while a conversation is open. /// with an exit. Paused entirely while a conversation is open.
fn step_cycle(&mut self) -> Vec<Event> { fn step_cycle(&mut self) -> Vec<Event> {
@ -411,6 +474,18 @@ impl GameState {
let exits = self.world.doc.exits; let exits = self.world.doc.exits;
let trig = 5; 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() { let leave = if self.ego.x <= 4 + trig && exits[3].is_some() {
Some((exits[3].unwrap(), 3u8)) Some((exits[3].unwrap(), 3u8))
} else if self.ego.x >= PIC_W as i32 - 4 - trig && exits[1].is_some() { } else if self.ego.x >= PIC_W as i32 - 4 - trig && exits[1].is_some() {
@ -423,6 +498,41 @@ impl GameState {
None None
}; };
if let Some((target, from)) = leave { 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); self.world.goto_room(target);
let inset = 14; let inset = 14;
match from { match from {
@ -439,8 +549,16 @@ impl GameState {
self.ego.dir = 0; self.ego.dir = 0;
self.ego.move_target = None; self.ego.move_target = None;
self.acc = 0.0; 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)); 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::Audio { cue: AudioCue::Door });
ev.push(Event::RoomChanged { room: self.world.current, music: self.world.doc.music }); ev.push(Event::RoomChanged { room: self.world.current, music: self.world.doc.music });
} }
@ -455,19 +573,30 @@ impl GameState {
ev.push(self.line(&echo)); ev.push(self.line(&echo));
let won_before = self.won; 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<usize> = None; let mut talk_target: Option<usize> = None;
let mut died = false;
let room_name = self.world.doc.name.clone();
let (lines, understood) = parser::run_command( let (lines, understood) = parser::run_command(
line, line,
&self.verbs, &self.verbs,
&self.world.doc.objects, &self.world.doc.objects,
self.world.current, self.world.current,
&room_name,
&mut self.inventory, &mut self.inventory,
&mut self.taken, &mut self.taken,
&mut self.won, &mut self.won,
&mut self.flags, &mut self.flags,
&mut talk_target, &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 { if let Some(i) = talk_target {
let nodes = self.world.doc.objects[i].dialogue.clone(); let nodes = self.world.doc.objects[i].dialogue.clone();
@ -493,7 +622,7 @@ impl GameState {
.doc .doc
.objects .objects
.iter() .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()) .map(|o| o.name.clone())
.collect(); .collect();
ev.push(self.line("\u{2026}(thinking)\u{2026}")); ev.push(self.line("\u{2026}(thinking)\u{2026}"));
@ -506,12 +635,19 @@ impl GameState {
for l in &lines { for l in &lines {
ev.push(self.line(l)); 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 { if self.won && !won_before {
ev.push(Event::Won); ev.push(Event::Won);
ev.push(Event::Audio { cue: AudioCue::Win }); 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::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 { } else if understood || from_ai {
ev.push(Event::Audio { cue: AudioCue::Confirm }); ev.push(Event::Audio { cue: AudioCue::Confirm });
} else { } else {
@ -542,9 +678,12 @@ impl GameState {
StateSnapshot { StateSnapshot {
game: self.world.manifest.name.clone(), game: self.world.manifest.name.clone(),
room, room,
room_name: self.world.doc.name.clone(),
ego: (self.ego.x, self.ego.y, self.ego.dir), ego: (self.ego.x, self.ego.y, self.ego.dir),
inventory: self.inventory.clone(), inventory: self.inventory.clone(),
won: self.won, won: self.won,
score: self.score,
max_score: self.world.manifest.max_score,
flags: self.flags.clone(), flags: self.flags.clone(),
vars: self.vars.clone(), vars: self.vars.clone(),
exits: self.world.doc.exits, exits: self.world.doc.exits,
@ -554,7 +693,7 @@ impl GameState {
.doc .doc
.objects .objects
.iter() .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 { .map(|o| ObjSummary {
name: o.name.clone(), name: o.name.clone(),
x: o.x, 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<Prop> { pub fn props(&self) -> Vec<Prop> {
let room = self.world.current; let room = self.world.current;
let mut props = Vec::new(); let mut props = Vec::new();
for o in &self.world.doc.objects { 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; continue;
} }
if let Some(cel) = self.world.sprite_cel(&o.sprite) { if let Some(cel) = self.world.sprite_cel(&o.sprite) {

View File

@ -54,7 +54,7 @@ pub enum Stroke {
Image { name: String, x: i32, y: i32, meaning: Meaning }, Image { name: String, x: i32, y: i32, meaning: Meaning },
} }
#[derive(Clone, Serialize, Deserialize)] #[derive(Clone, Default, Serialize, Deserialize)]
pub struct DlgChoice { pub struct DlgChoice {
pub text: String, pub text: String,
pub goto: i32, // next node index, or -1 to end the conversation 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). /// Picking this choice sets this flag true (empty = none).
#[serde(default)] #[serde(default)]
pub sets_flag: String, 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)] #[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 /// 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. /// 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 struct ObjDef {
pub name: String, pub name: String,
pub sprite: String, pub sprite: String,
@ -101,6 +109,35 @@ pub struct ObjDef {
pub sets_flag: String, pub sets_flag: String,
#[serde(default)] #[serde(default)]
pub dialogue: Vec<DlgNode>, // node 0 = entry; empty = no conversation 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)] #[derive(Clone, Serialize, Deserialize)]
@ -113,11 +150,37 @@ pub struct RoomDoc {
pub objects: Vec<ObjDef>, pub objects: Vec<ObjDef>,
#[serde(default)] #[serde(default)]
pub music: u8, // 0 = silence, 1.. = ambient mood 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 { impl Default for RoomDoc {
fn default() -> Self { 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. /// Initial flag values for a new game.
#[serde(default)] #[serde(default)]
pub flags: HashMap<String, bool>, 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 { fn default_name() -> String {
@ -154,6 +220,7 @@ impl Default for GameManifest {
intro_text: default_intro(), intro_text: default_intro(),
verbs: Vec::new(), verbs: Vec::new(),
flags: HashMap::new(), flags: HashMap::new(),
max_score: 0,
} }
} }
} }

View File

@ -16,24 +16,41 @@ fn obj(name: &str, takeable: bool, needs: &str, use_text: &str, wins: bool) -> O
y: 100, y: 100,
look: format!("It's a {}.", name), look: format!("It's a {}.", name),
takeable, takeable,
synonyms: String::new(),
use_text: use_text.into(), use_text: use_text.into(),
needs: needs.into(), needs: needs.into(),
wins, wins,
requires_flag: String::new(), ..Default::default()
sets_flag: String::new(), }
dialogue: Vec::new(), }
/// A bag of the parser's mutable state, so tests read like scripts.
#[derive(Default)]
struct Sim {
inv: Vec<String>,
taken: Vec<(u32, String)>,
won: bool,
flags: HashMap<String, bool>,
score: u32,
scored: Vec<(u32, String)>,
died: bool,
}
impl Sim {
fn run(&mut self, input: &str, objects: &[ObjDef]) -> (Vec<String>, 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). /// Run one line against a tiny fixture room; return (first line, understood).
fn run(input: &str, objects: &[ObjDef], inventory: &mut Vec<String>) -> (String, bool) { fn run(input: &str, objects: &[ObjDef], inventory: &mut Vec<String>) -> (String, bool) {
let verbs = default_verbs(); let mut sim = Sim { inv: std::mem::take(inventory), ..Default::default() };
let mut taken = Vec::new(); let (lines, understood) = sim.run(input, objects);
let mut won = false; *inventory = sim.inv;
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);
(lines.first().cloned().unwrap_or_default(), understood) (lines.first().cloned().unwrap_or_default(), understood)
} }
@ -57,21 +74,16 @@ fn parser_goldens() {
#[test] #[test]
fn use_respects_needs_lock() { fn use_respects_needs_lock() {
let objects = vec![obj("battery", true, "", "", false), obj("hatch", false, "battery", "It opens!", true)]; let objects = vec![obj("battery", true, "", "", false), obj("hatch", false, "battery", "It opens!", true)];
let verbs = default_verbs(); let mut sim = Sim::default();
let mut inv = Vec::new();
let mut taken = Vec::new();
let mut won = false;
let mut flags = HashMap::new();
let mut talk = None;
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_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); sim.run("take battery", &objects);
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 opens!"); assert_eq!(lines[0], "It opens!");
assert!(won); assert!(sim.won);
} }
#[test] #[test]
@ -81,20 +93,18 @@ fn use_respects_flag_gate_and_sets_flags() {
let mut door = obj("door", false, "", "The door slides open.", true); let mut door = obj("door", false, "", "The door slides open.", true);
door.requires_flag = "power_on".into(); door.requires_flag = "power_on".into();
let objects = vec![lever, door]; let objects = vec![lever, door];
let mut sim = Sim::default();
let verbs = default_verbs(); let (lines, _) = sim.run("use door", &objects);
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);
assert_eq!(lines[0], "Nothing happens. Something else must come first."); 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); sim.run("use lever", &objects);
assert_eq!(flags.get("power_on"), Some(&true)); 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_eq!(lines[0], "The door slides open.");
assert!(won); assert!(sim.won);
} }
#[test] #[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 extra = vec![mrpgi_core::parser::VerbDef { name: "take".into(), words: vec!["yoink".into()] }];
let verbs = build_verbs(&extra); let verbs = build_verbs(&extra);
let objects = vec![obj("battery", true, "", "", false)]; 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 mut sim = Sim::default();
let (lines, understood) = run_command("yoink battery", &verbs, &objects, 0, &mut inv, &mut taken, &mut won, &mut flags, &mut talk); 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!(understood);
assert_eq!(lines[0], "You take the battery."); 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 — /// Play the shipped sample game start → win, entirely over the bus, twice —
/// and require the two transcripts to be byte-identical (replay determinism). /// and require the two transcripts to be byte-identical (replay determinism).
#[test] #[test]
@ -148,6 +215,120 @@ fn sample_game_win_path_is_deterministic() {
std::fs::remove_dir_all(&dir).ok(); 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. /// Headless render smoke test: the frame is a real PNG with the right size.
#[test] #[test]
fn renders_a_png_with_no_window() { fn renders_a_png_with_no_window() {

View File

@ -390,13 +390,7 @@ impl Editor {
y: cpy, y: cpy,
look, look,
takeable: true, takeable: true,
synonyms: String::new(), ..Default::default()
use_text: String::new(),
needs: String::new(),
wins: false,
requires_flag: String::new(),
sets_flag: String::new(),
dialogue: Vec::new(),
}); });
self.selected_obj = Some(gs.world.doc.objects.len() - 1); self.selected_obj = Some(gs.world.doc.objects.len() - 1);
self.status = "placed object — Tab to play, then 'look <it>'".into(); self.status = "placed object — Tab to play, then 'look <it>'".into();

View File

@ -440,6 +440,12 @@ async fn main() {
draw_scanlines(&layout); draw_scanlines(&layout);
} }
draw_menus(&menus, menu_open, mmx, mmy); 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); draw_play_panel(&gs, &command, crt, &ai_label, !audio.muted);
if gs.won { if gs.won {
let sw2 = screen_width(); let sw2 = screen_width();