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}`
(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

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(),
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<Path>) {
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<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"),
],
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<Path>) {
"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<Path>) {
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<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"),
],
music: 2,
..Default::default()
};
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"),
],
music: 3,
..Default::default()
};
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"),
],
music: 4,
..Default::default()
};
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
//! 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<usize> {
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<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 {
@ -106,11 +119,15 @@ pub fn run_command(
verbs: &[VerbDef],
objects: &[ObjDef],
room: u32,
room_name: &str,
inventory: &mut Vec<String>,
taken: &mut Vec<(u32, String)>,
won: &mut bool,
flags: &mut HashMap<String, bool>,
talk_target: &mut Option<usize>,
score: &mut u32,
scored: &mut Vec<(u32, String)>,
died: &mut bool,
) -> (Vec<String>, 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<String> = 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,10 +203,20 @@ pub fn run_command(
if !objects[i].sets_flag.is_empty() {
flags.insert(objects[i].sets_flag.clone(), true);
}
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
}
} else {
@ -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<String, bool>) -> (Vec<String>, 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<String, bool>,
score: &mut u32,
) -> (Vec<String>, 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)
}
}

View File

@ -79,6 +79,10 @@ pub enum Event {
DialogueOpen { lines: Vec<String> },
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<String>,
pub won: bool,
pub score: u32,
pub max_score: u32,
pub flags: HashMap<String, bool>,
pub vars: HashMap<String, i32>,
pub exits: [Option<u32>; 4],
@ -122,6 +129,9 @@ pub struct GameState {
pub inventory: Vec<String>,
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<Dlg>,
pub transcript: Vec<String>,
pub flags: HashMap<String, bool>,
@ -130,6 +140,9 @@ pub struct GameState {
pub ai: ai::AiConfig,
pending: Option<std::sync::mpsc::Receiver<String>>,
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<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
/// with an exit. Paused entirely while a conversation is open.
fn step_cycle(&mut self) -> Vec<Event> {
@ -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<usize> = 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() });
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<Prop> {
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) {

View File

@ -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<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)]
@ -113,11 +150,37 @@ pub struct RoomDoc {
pub objects: Vec<ObjDef>,
#[serde(default)]
pub music: u8, // 0 = silence, 1.. = ambient mood
/// Display name, shown on entry and in `look` ("" = just the number).
#[serde(default)]
pub name: String,
/// Prose printed when the ego enters the room ("" = the old stock line).
#[serde(default)]
pub enter_text: String,
/// Per-direction flag required before that exit works ("" = open). N,E,S,W.
#[serde(default = "empty4")]
pub exit_flags: [String; 4],
/// What to say when the matching exit refuses ("" = a stock line). N,E,S,W.
#[serde(default = "empty4")]
pub exit_blocked: [String; 4],
}
fn empty4() -> [String; 4] {
[String::new(), String::new(), String::new(), String::new()]
}
impl Default for RoomDoc {
fn default() -> Self {
RoomDoc { strokes: Vec::new(), spawn: (80, 150), exits: [None; 4], objects: Vec::new(), music: 0 }
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<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 {
@ -154,6 +220,7 @@ impl Default for GameManifest {
intro_text: default_intro(),
verbs: Vec::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,
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<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).
fn run(input: &str, objects: &[ObjDef], inventory: &mut Vec<String>) -> (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() {

View File

@ -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 <it>'".into();

View File

@ -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();