//! 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, } 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 { 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 { 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 = 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, noun: &str, ) -> Option { 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 { 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, 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, taken: &mut Vec<(u32, String)>, won: &mut bool, flags: &mut HashMap, talk_target: &mut Option, score: &mut u32, scored: &mut Vec<(u32, String)>, died: &mut bool, ) -> (Vec, 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 = 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, pub node: usize, } impl Dlg { fn visible(&self, flags: &HashMap) -> Vec { 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) -> Vec { 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) -> 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, score: &mut u32, ) -> (Vec, 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) } }