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}`
|
**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
|
||||||
|
|
||||||
|
|||||||
@ -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)];
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user