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>
320 lines
12 KiB
Rust
320 lines
12 KiB
Rust
//! The deterministic verb-noun parser, its data-driven verb table, and live
|
|
//! 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::{obj_visible, DlgNode, ObjDef};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::HashMap;
|
|
|
|
const IGNORE: &[&str] = &["the", "a", "an", "at", "to", "with", "on", "of", "my", "your", "some", "please", "go", "up"];
|
|
|
|
/// One canonical verb and every word that maps to it. The same table drives
|
|
/// the parser AND the AI prompt whitelist, so the two can never drift.
|
|
#[derive(Clone, Serialize, Deserialize)]
|
|
pub struct VerbDef {
|
|
pub name: String,
|
|
pub words: Vec<String>,
|
|
}
|
|
|
|
fn v(name: &str, words: &[&str]) -> VerbDef {
|
|
VerbDef { name: name.into(), words: words.iter().map(|s| s.to_string()).collect() }
|
|
}
|
|
|
|
/// The built-in verb table (the seven AGI-spirit verbs).
|
|
pub fn default_verbs() -> Vec<VerbDef> {
|
|
vec![
|
|
v("look", &["look", "l", "examine", "x", "inspect", "check", "read", "view", "see", "study"]),
|
|
v("take", &["take", "get", "grab", "pick", "snatch", "steal", "pocket", "collect", "nab"]),
|
|
v("use", &["use", "open", "unlock", "activate", "push", "pull", "turn", "operate"]),
|
|
v("talk", &["talk", "speak", "ask", "chat", "greet", "say"]),
|
|
v("drop", &["drop", "discard", "leave", "put"]),
|
|
v("inventory", &["inventory", "inv", "i", "items", "bag"]),
|
|
v("help", &["help", "?", "commands", "verbs"]),
|
|
]
|
|
}
|
|
|
|
/// Built-ins + a game's extra synonyms (merged by canonical name; unknown
|
|
/// canonical names are appended so the words at least parse as that verb).
|
|
pub fn build_verbs(extra: &[VerbDef]) -> Vec<VerbDef> {
|
|
let mut verbs = default_verbs();
|
|
for e in extra {
|
|
if let Some(existing) = verbs.iter_mut().find(|d| d.name == e.name) {
|
|
for w in &e.words {
|
|
if !existing.words.contains(w) {
|
|
existing.words.push(w.clone());
|
|
}
|
|
}
|
|
} else {
|
|
verbs.push(e.clone());
|
|
}
|
|
}
|
|
verbs
|
|
}
|
|
|
|
/// Map a typed word to its canonical verb ("" if unknown).
|
|
pub fn canon_verb<'a>(verbs: &'a [VerbDef], w: &str) -> &'a str {
|
|
verbs
|
|
.iter()
|
|
.find(|d| d.words.iter().any(|word| word == w))
|
|
.map(|d| d.name.as_str())
|
|
.unwrap_or("")
|
|
}
|
|
|
|
/// Every word the AI is allowed to use, for the prompt whitelist.
|
|
pub fn verb_whitelist(verbs: &[VerbDef]) -> String {
|
|
let mut words: Vec<&str> = Vec::new();
|
|
for d in verbs {
|
|
for w in &d.words {
|
|
if w.len() > 1 && words.len() < 24 && !words.contains(&w.as_str()) {
|
|
words.push(w);
|
|
}
|
|
}
|
|
}
|
|
words.join(", ")
|
|
}
|
|
|
|
/// Does this object answer to `noun` (its name or any synonym, loosely)?
|
|
fn obj_matches(o: &ObjDef, noun: &str) -> bool {
|
|
if noun.is_empty() {
|
|
return false;
|
|
}
|
|
let mut names: Vec<String> = vec![o.name.to_lowercase()];
|
|
for s in o.synonyms.split_whitespace() {
|
|
names.push(s.to_lowercase());
|
|
}
|
|
names.iter().any(|nm| nm.as_str() == noun || nm.contains(noun) || noun.contains(nm.as_str()))
|
|
}
|
|
|
|
/// 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 {
|
|
name.is_empty() || flags.get(name).copied().unwrap_or(false)
|
|
}
|
|
|
|
/// The parser: typed line → ignore-words stripped → canonical verb + noun →
|
|
/// response. Deterministic; the AI lane plugs into the catch-all branch.
|
|
/// Returns (lines to print, understood).
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn run_command(
|
|
input: &str,
|
|
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();
|
|
if words.is_empty() {
|
|
return (vec![], true);
|
|
}
|
|
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| obj_visible(o, room, taken, flags))
|
|
.map(|o| o.name.clone())
|
|
.collect();
|
|
if names.is_empty() {
|
|
vec![format!("{}. Nothing in particular stands out.", title)]
|
|
} else {
|
|
vec![format!("{}. You see: {}.", title, names.join(", "))]
|
|
}
|
|
} 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, flags, &noun) {
|
|
if objects[i].takeable {
|
|
inventory.push(objects[i].name.clone());
|
|
taken.push((room, objects[i].name.clone()));
|
|
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)]
|
|
}
|
|
} else {
|
|
vec![format!("There's no '{}' to take.", noun)]
|
|
}
|
|
}
|
|
"drop" => {
|
|
if let Some(p) = inventory.iter().position(|n| !noun.is_empty() && n.to_lowercase().contains(noun.as_str())) {
|
|
let n = inventory.remove(p);
|
|
vec![format!("You drop the {}.", n)]
|
|
} else {
|
|
vec![format!("You aren't carrying a '{}'.", noun)]
|
|
}
|
|
}
|
|
"inventory" => {
|
|
if inventory.is_empty() {
|
|
vec!["You're carrying nothing.".to_string()]
|
|
} else {
|
|
vec![format!("Carrying: {}.", inventory.join(", "))]
|
|
}
|
|
}
|
|
"use" => {
|
|
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)]
|
|
} else if !flag_ok(flags, &objects[i].requires_flag) {
|
|
vec!["Nothing happens. Something else must come first.".to_string()]
|
|
} else {
|
|
let mut out = vec![if objects[i].use_text.is_empty() {
|
|
format!("You use the {}. Nothing obvious happens.", objects[i].name)
|
|
} else {
|
|
objects[i].use_text.clone()
|
|
}];
|
|
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 {
|
|
vec![format!("There's no '{}' to use.", noun)]
|
|
}
|
|
}
|
|
"talk" => {
|
|
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 {
|
|
*talk_target = Some(i);
|
|
vec![]
|
|
}
|
|
} else {
|
|
vec![format!("There's no '{}' to talk to.", noun)]
|
|
}
|
|
}
|
|
"help" => {
|
|
let names: Vec<&str> = verbs.iter().map(|d| d.name.as_str()).collect();
|
|
vec![format!("Verbs: {}. Arrows walk.", names.join(" \u{00B7} "))]
|
|
}
|
|
_ => vec![format!("I don't understand \"{}\".", input.trim())],
|
|
};
|
|
(lines, understood)
|
|
}
|
|
|
|
/// A live, in-progress conversation (a clone of an NPC's dialogue tree).
|
|
/// Choices gated by `requires_flag` are hidden until the flag is set; the
|
|
/// numbers the player sees always match what `choose` accepts.
|
|
#[derive(Clone)]
|
|
pub struct Dlg {
|
|
pub nodes: Vec<DlgNode>,
|
|
pub node: usize,
|
|
}
|
|
|
|
impl Dlg {
|
|
fn visible(&self, flags: &HashMap<String, bool>) -> Vec<usize> {
|
|
self.nodes[self.node]
|
|
.choices
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(_, c)| flag_ok(flags, &c.requires_flag))
|
|
.map(|(i, _)| i)
|
|
.collect()
|
|
}
|
|
|
|
pub fn lines(&self, flags: &HashMap<String, bool>) -> Vec<String> {
|
|
let n = &self.nodes[self.node];
|
|
let mut out = vec![format!("\u{201C}{}\u{201D}", n.says)];
|
|
for (shown, i) in self.visible(flags).iter().enumerate() {
|
|
out.push(format!(" {}) {}", shown + 1, n.choices[*i].text));
|
|
}
|
|
out
|
|
}
|
|
|
|
pub fn terminal(&self, flags: &HashMap<String, bool>) -> bool {
|
|
self.visible(flags).is_empty()
|
|
}
|
|
|
|
/// 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, false);
|
|
}
|
|
let c = self.nodes[self.node].choices[vis[k - 1]].clone();
|
|
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);
|
|
}
|
|
if c.goto < 0 || c.goto as usize >= self.nodes.len() {
|
|
return (out, true, false);
|
|
}
|
|
self.node = c.goto as usize;
|
|
out.extend(self.lines(flags));
|
|
(out, self.terminal(flags), false)
|
|
}
|
|
}
|