use crate::interactive_fiction::data::{
Choice, ChoiceAction, EntityKind, ExamineTarget, Placeholder, RefusalCategory, RuntimeState,
Verb, VerbResponses,
};
use crate::interactive_fiction::engine::Engine;
use std::str::FromStr;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Parsed {
Choose(usize),
Quit,
Undo,
Help,
DescribeRoom,
TakeAll,
DropAll,
ExamineAll,
Empty,
NoMatch,
Ambiguous,
Refuse(String),
}
pub fn extract_noun(raw: &str) -> Option<String> {
let normalized = raw.trim().to_lowercase();
if normalized.is_empty() {
return None;
}
match split_verb_and_noun(&normalized) {
Some((_, noun)) if !noun.is_empty() => Some(noun),
_ => None,
}
}
pub fn parse(engine: &Engine, state: &RuntimeState, choices: &[Choice], raw: &str) -> Parsed {
let normalized = raw.trim().to_lowercase();
if normalized.is_empty() {
return Parsed::Empty;
}
match normalized.as_str() {
"q" | "quit" => return Parsed::Quit,
"u" | "undo" | "oops" => return Parsed::Undo,
"help" | "h" | "commands" | "?" => return Parsed::Help,
_ => {}
}
if let Ok(number) = normalized.parse::<usize>()
&& number >= 1
&& number <= choices.len()
{
return Parsed::Choose(number - 1);
}
if let Some(matched) = direction_only(engine, state, choices, &normalized) {
return matched;
}
let (verb, noun) = match split_verb_and_noun(&normalized) {
Some(parsed) => parsed,
None => return label_match(engine, state, choices, &normalized),
};
let parsed = dispatch_verb(engine, state, choices, verb, &noun);
if !matches!(parsed, Parsed::NoMatch) {
return parsed;
}
if !noun.is_empty() && verb.accepts_examine_fallback() {
let fallback = find_examine(engine, choices, &noun);
if !matches!(fallback, Parsed::NoMatch) {
return fallback;
}
}
let labeled = label_match(engine, state, choices, &normalized);
if !matches!(labeled, Parsed::NoMatch) {
return labeled;
}
Parsed::Refuse(refusal_for(engine, verb, &noun))
}
fn split_verb_and_noun(input: &str) -> Option<(Verb, String)> {
if let Some((verb, rest)) = match_compound_phrase(input) {
return Some((verb, normalize_noun(rest)));
}
let (head, rest) = split_head(input);
let verb = Verb::from_str(head).ok()?;
let noun = normalize_noun_for_verb(verb, rest);
Some((verb, noun))
}
const COMPOUND_PHRASES: &[(&str, Verb)] = &[
("pick up", Verb::Take),
("put down", Verb::Drop),
("put on", Verb::Wear),
("take off", Verb::Remove),
("open up", Verb::Open),
("look at", Verb::Examine),
("look into", Verb::Examine),
("look inside", Verb::Examine),
("look in", Verb::Examine),
("look under", Verb::Examine),
("look behind", Verb::Examine),
("look on", Verb::Examine),
("listen to", Verb::Listen),
("talk to", Verb::Talk),
("talk with", Verb::Talk),
("speak to", Verb::Talk),
("speak with", Verb::Talk),
("wake up", Verb::Wake),
("turn on", Verb::Use),
("turn off", Verb::Use),
("switch on", Verb::Use),
("switch off", Verb::Use),
("climb up", Verb::Climb),
("climb on", Verb::Climb),
("climb down", Verb::Climb),
("climb off", Verb::Climb),
("climb onto", Verb::Climb),
("get in", Verb::Enter),
("get into", Verb::Enter),
("get inside", Verb::Enter),
("get on", Verb::Enter),
("get out", Verb::Leave),
("get off", Verb::Leave),
("go in", Verb::Enter),
("go into", Verb::Enter),
("go inside", Verb::Enter),
("go out", Verb::Leave),
("go outside", Verb::Leave),
];
fn match_compound_phrase(input: &str) -> Option<(Verb, &str)> {
for (phrase, verb) in COMPOUND_PHRASES {
if let Some(rest) = strip_phrase_prefix(input, phrase) {
return Some((*verb, rest));
}
}
None
}
fn strip_phrase_prefix<'a>(input: &'a str, phrase: &str) -> Option<&'a str> {
let rest = input.strip_prefix(phrase)?;
if rest.is_empty() {
return Some(rest);
}
if rest.starts_with(char::is_whitespace) {
Some(rest.trim_start())
} else {
None
}
}
fn split_head(input: &str) -> (&str, &str) {
match input.find(char::is_whitespace) {
Some(idx) => (&input[..idx], input[idx..].trim_start()),
None => (input, ""),
}
}
fn normalize_noun(rest: &str) -> String {
rest.trim()
.trim_start_matches("the ")
.trim_start_matches("a ")
.trim_start_matches("an ")
.trim()
.to_string()
}
fn normalize_noun_for_verb(verb: Verb, rest: &str) -> String {
let trimmed = rest.trim();
let without_prep = match verb {
Verb::Talk => strip_leading_word(trimmed, &["to", "with"]),
Verb::Look => strip_leading_word(trimmed, &["at"]),
Verb::Listen => strip_leading_word(trimmed, &["to"]),
_ => trimmed,
};
let primary_object = match verb {
Verb::Ask | Verb::Consult => split_on_connector(without_prep, &[" about ", " regarding "]),
Verb::Give | Verb::Show => split_after_connector(without_prep, &[" to "]),
_ => without_prep,
};
normalize_noun(primary_object)
}
fn strip_leading_word<'a>(input: &'a str, prepositions: &[&str]) -> &'a str {
for prep in prepositions {
if let Some(rest) = input.strip_prefix(prep)
&& (rest.is_empty() || rest.starts_with(char::is_whitespace))
{
return rest.trim_start();
}
}
input
}
fn split_on_connector<'a>(input: &'a str, connectors: &[&str]) -> &'a str {
for connector in connectors {
if let Some(idx) = input.find(connector) {
return &input[..idx];
}
}
input
}
fn split_after_connector<'a>(input: &'a str, connectors: &[&str]) -> &'a str {
for connector in connectors {
if let Some(idx) = input.find(connector) {
return input[idx + connector.len()..].trim_start();
}
}
input
}
fn refusal_for(engine: &Engine, verb: Verb, noun: &str) -> String {
let responses = &engine.world().verb_responses;
let category = responses.refusal_category(verb);
let template = match category {
RefusalCategory::Default => &responses.refusal_default,
RefusalCategory::Taste => &responses.refusal_taste,
RefusalCategory::Consume => &responses.refusal_consume,
RefusalCategory::Violence => &responses.refusal_violence,
RefusalCategory::Affection => &responses.refusal_affection,
RefusalCategory::Manipulation => &responses.refusal_manipulation,
RefusalCategory::Jump => &responses.refusal_jump,
RefusalCategory::Exchange => &responses.refusal_exchange,
RefusalCategory::Wake => &responses.refusal_wake,
};
VerbResponses::render(template, &[(Placeholder::Noun, noun)])
}
fn dispatch_verb(
engine: &Engine,
state: &RuntimeState,
choices: &[Choice],
verb: Verb,
noun: &str,
) -> Parsed {
match verb {
Verb::Look => {
if noun.is_empty() || is_room_reference(engine, state, noun) {
describe_room_or_look_action(choices)
} else {
find_examine(engine, choices, noun)
}
}
Verb::Examine | Verb::Smell | Verb::Listen | Verb::Touch => {
if noun.is_empty() || is_room_reference(engine, state, noun) {
describe_room_or_look_action(choices)
} else if is_all_keyword(noun)
&& has_any(choices, |action| matches!(action, ChoiceAction::Examine(_)))
{
Parsed::ExamineAll
} else {
find_examine(engine, choices, noun)
}
}
Verb::Eat | Verb::Drink => consume_response_for(engine, state, noun),
Verb::Taste => Parsed::NoMatch,
Verb::Exits => describe_room_or_look_action(choices),
Verb::Inventory => find_simple(choices, |action| matches!(action, ChoiceAction::Inventory)),
Verb::Wait => find_simple(choices, |action| matches!(action, ChoiceAction::Wait)),
Verb::Leave => {
let dialogue_leave = find_simple(choices, |action| {
matches!(action, ChoiceAction::LeaveDialogue)
});
if !matches!(dialogue_leave, Parsed::NoMatch) {
dialogue_leave
} else {
go_for_keyword(engine, state, choices, "out")
}
}
Verb::Take => {
if is_all_keyword(noun) {
if has_any(choices, |action| matches!(action, ChoiceAction::Take(_))) {
Parsed::TakeAll
} else {
Parsed::NoMatch
}
} else {
item_verb(engine, choices, noun, |action| {
matches!(action, ChoiceAction::Take(_))
})
}
}
Verb::Drop => {
if is_all_keyword(noun) {
if has_any(choices, |action| matches!(action, ChoiceAction::Drop(_))) {
Parsed::DropAll
} else {
Parsed::NoMatch
}
} else {
item_verb(engine, choices, noun, |action| {
matches!(action, ChoiceAction::Drop(_))
})
}
}
Verb::Throw | Verb::Put | Verb::Remove => item_verb(engine, choices, noun, |action| {
matches!(action, ChoiceAction::Drop(_))
}),
Verb::Use
| Verb::Open
| Verb::Close
| Verb::Unlock
| Verb::Lock
| Verb::Switch
| Verb::Push
| Verb::Pull
| Verb::Turn
| Verb::Squeeze
| Verb::Rub
| Verb::Wave
| Verb::Swing
| Verb::Burn
| Verb::Cut
| Verb::Tie
| Verb::Wear
| Verb::Insert
| Verb::Fill => {
let entity = entity_open(engine, choices, noun, KindFilter::Any);
if !matches!(entity, Parsed::NoMatch) {
entity
} else {
item_verb(engine, choices, noun, |action| {
matches!(action, ChoiceAction::Use(_))
})
}
}
Verb::Read | Verb::Consult => item_verb(engine, choices, noun, |action| {
matches!(action, ChoiceAction::Read(_))
}),
Verb::Go => find_go(engine, state, choices, noun),
Verb::Enter => {
if noun.is_empty() {
go_for_keyword(engine, state, choices, "in")
} else {
find_go(engine, state, choices, noun)
}
}
Verb::Climb => {
if noun.is_empty() {
go_for_keyword(engine, state, choices, "up")
} else {
find_go(engine, state, choices, noun)
}
}
Verb::Jump => Parsed::NoMatch,
Verb::Talk | Verb::Ask => {
entity_open(engine, choices, noun, KindFilter::Any)
}
Verb::Kiss | Verb::Attack | Verb::Wake | Verb::Apologise => {
entity_open(engine, choices, noun, KindFilter::CharacterOnly)
}
Verb::Give | Verb::Show | Verb::Buy => Parsed::NoMatch,
}
}
fn has_any(choices: &[Choice], predicate: impl Fn(&ChoiceAction) -> bool) -> bool {
choices.iter().any(|choice| predicate(&choice.action))
}
fn consume_response_for(engine: &Engine, state: &RuntimeState, noun: &str) -> Parsed {
use crate::interactive_fiction::data::ItemLocation;
let effective = if noun.is_empty() {
state.last_noun.as_deref().unwrap_or("")
} else {
noun
};
if effective.is_empty() {
return Parsed::NoMatch;
}
for (item_id, item) in &engine.world().items {
if !item_matches_noun(engine, item_id, effective) {
continue;
}
let reachable = match state.item_locations.get(item_id) {
Some(ItemLocation::Inventory) => true,
Some(ItemLocation::Room(r)) => r == &state.current_room,
_ => false,
};
if !reachable {
continue;
}
if let Some(response) = &item.properties.consume_response {
let rendered = engine.resolve_text(state, response);
return Parsed::Refuse(rendered);
}
}
Parsed::NoMatch
}
fn describe_room_or_look_action(choices: &[Choice]) -> Parsed {
let via_menu = find_simple(choices, |action| matches!(action, ChoiceAction::Look));
if matches!(via_menu, Parsed::NoMatch) {
Parsed::DescribeRoom
} else {
via_menu
}
}
fn item_verb(
engine: &Engine,
choices: &[Choice],
rest: &str,
action_matches: impl Fn(&ChoiceAction) -> bool,
) -> Parsed {
if rest.is_empty() {
return Parsed::NoMatch;
}
let matches = indexes_of_item_action(engine, choices, rest, action_matches);
match matches.len() {
0 => Parsed::NoMatch,
1 => Parsed::Choose(matches[0]),
_ => Parsed::Ambiguous,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum KindFilter {
Any,
CharacterOnly,
}
fn entity_open(engine: &Engine, choices: &[Choice], noun: &str, kind: KindFilter) -> Parsed {
if noun.is_empty() {
return Parsed::NoMatch;
}
let noun = noun
.trim_start_matches("the ")
.trim_start_matches("a ")
.trim_start_matches("an ")
.trim();
let matches: Vec<usize> = choices
.iter()
.enumerate()
.filter_map(|(i, choice)| {
let ChoiceAction::Open(entity_id) = &choice.action else {
return None;
};
if !entity_matches_noun(engine, entity_id, noun) {
return None;
}
if kind == KindFilter::CharacterOnly {
let entity = engine.world().entities.get(entity_id)?;
if !matches!(entity.kind, EntityKind::Character { .. }) {
return None;
}
}
Some(i)
})
.collect();
match matches.len() {
0 => Parsed::NoMatch,
1 => Parsed::Choose(matches[0]),
_ => Parsed::Ambiguous,
}
}
fn is_all_keyword(noun: &str) -> bool {
matches!(noun.trim(), "all" | "everything")
}
fn is_room_reference(engine: &Engine, state: &RuntimeState, noun: &str) -> bool {
let trimmed = noun.trim();
if matches!(
trimmed,
"around" | "here" | "room" | "place" | "surroundings"
) {
return true;
}
let Some(room) = engine.world().rooms.get(&state.current_room) else {
return false;
};
noun_matches_phrase(&room.name, trimmed)
}
fn find_simple(choices: &[Choice], predicate: impl Fn(&ChoiceAction) -> bool) -> Parsed {
let matches: Vec<usize> = choices
.iter()
.enumerate()
.filter(|(_, choice)| predicate(&choice.action))
.map(|(i, _)| i)
.collect();
match matches.len() {
0 => Parsed::NoMatch,
1 => Parsed::Choose(matches[0]),
_ => Parsed::Ambiguous,
}
}
fn direction_only(
engine: &Engine,
state: &RuntimeState,
choices: &[Choice],
input: &str,
) -> Option<Parsed> {
let expanded = expand_direction(input)?;
let matches = go_indexes_for_direction(engine, state, choices, expanded);
Some(match matches.len() {
0 => return None,
1 => Parsed::Choose(matches[0]),
_ => Parsed::Ambiguous,
})
}
fn find_go(engine: &Engine, state: &RuntimeState, choices: &[Choice], rest: &str) -> Parsed {
if rest.is_empty() {
return Parsed::NoMatch;
}
let wanted = expand_direction(rest).unwrap_or(rest);
go_for_keyword(engine, state, choices, wanted)
}
fn go_for_keyword(
engine: &Engine,
state: &RuntimeState,
choices: &[Choice],
keyword: &str,
) -> Parsed {
let matches = go_indexes_for_direction(engine, state, choices, keyword);
match matches.len() {
0 => Parsed::NoMatch,
1 => Parsed::Choose(matches[0]),
_ => Parsed::Ambiguous,
}
}
fn go_indexes_for_direction(
engine: &Engine,
state: &RuntimeState,
choices: &[Choice],
wanted: &str,
) -> Vec<usize> {
let Some(room) = engine.world().rooms.get(&state.current_room) else {
return Vec::new();
};
choices
.iter()
.enumerate()
.filter_map(|(i, choice)| {
let ChoiceAction::Go { exit_index, .. } = choice.action else {
return None;
};
let exit = room.exits.get(exit_index)?;
let direction = exit.direction.to_lowercase();
if matches_direction(&direction, wanted) {
Some(i)
} else {
None
}
})
.collect()
}
fn matches_direction(direction: &str, wanted: &str) -> bool {
if direction == wanted {
return true;
}
if direction.starts_with(wanted) {
return true;
}
let first_word = direction.split_whitespace().next().unwrap_or("");
if first_word == wanted || first_word.starts_with(wanted) {
return true;
}
direction
.split_whitespace()
.any(|word| word.trim_matches(|character: char| !character.is_alphanumeric()) == wanted)
}
fn expand_direction(input: &str) -> Option<&'static str> {
let trimmed = input.trim();
Some(match trimmed {
"n" | "north" => "north",
"s" | "south" => "south",
"e" | "east" => "east",
"w" | "west" => "west",
"up" => "up",
"d" | "down" => "down",
"ne" | "northeast" => "northeast",
"nw" | "northwest" => "northwest",
"se" | "southeast" => "southeast",
"sw" | "southwest" => "southwest",
"in" | "inside" => "in",
"out" | "outside" => "out",
_ => return None,
})
}
fn find_examine(engine: &Engine, choices: &[Choice], noun: &str) -> Parsed {
if noun.is_empty() {
return Parsed::NoMatch;
}
let noun = noun
.trim_start_matches("the ")
.trim_start_matches("a ")
.trim_start_matches("an ")
.trim();
let mut exact: Vec<usize> = Vec::new();
let mut partial: Vec<usize> = Vec::new();
for (index, choice) in choices.iter().enumerate() {
let ChoiceAction::Examine(target) = &choice.action else {
continue;
};
let phrase = match target {
ExamineTarget::Item(item) => engine.world().items.get(item).map(|i| i.name.clone()),
ExamineTarget::Entity(entity) => {
engine.world().entities.get(entity).map(|e| e.name.clone())
}
ExamineTarget::Keyword(keyword) => Some(keyword.clone()),
};
let synonyms: Vec<String> = match target {
ExamineTarget::Item(item) => engine
.world()
.items
.get(item)
.map(|i| i.synonyms.clone())
.unwrap_or_default(),
ExamineTarget::Entity(entity) => engine
.world()
.entities
.get(entity)
.map(|e| e.synonyms.clone())
.unwrap_or_default(),
ExamineTarget::Keyword(_) => Vec::new(),
};
let mut exact_hit = false;
let mut partial_hit = false;
if let Some(phrase) = &phrase {
if phrase_exact_match(phrase, noun) {
exact_hit = true;
} else if noun_matches_phrase(phrase, noun) {
partial_hit = true;
}
}
for synonym in &synonyms {
if phrase_exact_match(synonym, noun) {
exact_hit = true;
} else if noun_matches_phrase(synonym, noun) {
partial_hit = true;
}
}
if exact_hit {
exact.push(index);
} else if partial_hit {
partial.push(index);
}
}
let winners = if !exact.is_empty() { exact } else { partial };
match winners.len() {
0 => Parsed::NoMatch,
1 => Parsed::Choose(winners[0]),
_ => Parsed::Ambiguous,
}
}
fn phrase_exact_match(phrase: &str, noun: &str) -> bool {
strip_articles(&phrase.to_lowercase()) == strip_articles(&noun.to_lowercase())
}
fn indexes_of_item_action(
engine: &Engine,
choices: &[Choice],
rest: &str,
action_matches: impl Fn(&ChoiceAction) -> bool,
) -> Vec<usize> {
let noun = rest
.trim_start_matches("the ")
.trim_start_matches("a ")
.trim_start_matches("an ")
.trim();
if noun.is_empty() {
return Vec::new();
}
choices
.iter()
.enumerate()
.filter_map(|(i, choice)| {
if !action_matches(&choice.action) {
return None;
}
let item_id = match &choice.action {
ChoiceAction::Take(id)
| ChoiceAction::Drop(id)
| ChoiceAction::Use(id)
| ChoiceAction::Read(id) => id,
ChoiceAction::Examine(ExamineTarget::Item(id)) => id,
_ => return None,
};
if item_matches_noun(engine, item_id, noun) {
Some(i)
} else {
None
}
})
.collect()
}
fn item_matches_noun(
engine: &Engine,
item_id: &crate::interactive_fiction::data::ItemId,
noun: &str,
) -> bool {
let Some(item) = engine.world().items.get(item_id) else {
return false;
};
if noun_matches_phrase(&item.name, noun) {
return true;
}
item.synonyms
.iter()
.any(|synonym| noun_matches_phrase(synonym, noun))
}
fn entity_matches_noun(
engine: &Engine,
entity_id: &crate::interactive_fiction::data::EntityId,
noun: &str,
) -> bool {
let Some(entity) = engine.world().entities.get(entity_id) else {
return false;
};
if noun_matches_phrase(&entity.name, noun) {
return true;
}
entity
.synonyms
.iter()
.any(|synonym| noun_matches_phrase(synonym, noun))
}
fn noun_matches_phrase(phrase: &str, noun: &str) -> bool {
let phrase = strip_articles(&phrase.to_lowercase());
let noun = strip_articles(&noun.to_lowercase());
if phrase == noun {
return true;
}
phrase.split_whitespace().any(|word| {
let word = word.trim_matches(|character: char| !character.is_alphanumeric());
word == noun
})
}
fn strip_articles(phrase: &str) -> String {
phrase
.trim_start_matches("the ")
.trim_start_matches("a ")
.trim_start_matches("an ")
.trim()
.to_string()
}
fn label_match(engine: &Engine, state: &RuntimeState, choices: &[Choice], input: &str) -> Parsed {
let mut matches: Vec<usize> = Vec::new();
for (index, choice) in choices.iter().enumerate() {
let label = engine.resolve_text(state, &choice.label).to_lowercase();
if label.contains(input) {
matches.push(index);
}
}
match matches.len() {
0 => Parsed::NoMatch,
1 => Parsed::Choose(matches[0]),
_ => Parsed::Ambiguous,
}
}