MRPGI/mrpgi-core/src/parser.rs
type-two 755168a969 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>
2026-07-05 15:30:32 +10:00

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)
}
}