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:
parent
eb4a21491e
commit
755168a969
@ -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
|
||||
|
||||
|
||||
@ -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)];
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user