nightshade 0.13.2

A cross-platform data-oriented game engine.
Documentation
//! Every player-facing string the engine emits on its own initiative.
//!
//! The engine never hard-codes flavour text. When it needs to narrate a
//! take, a drop, a dialogue speaker fallback, or a choice-menu label, it
//! reads the relevant field from `World.verb_responses` and substitutes
//! any `{placeholder}` tokens via [`VerbResponses::render`].
//!
//! Authors can override any field when building a world; the defaults are
//! plain English and match what the engine used to hard-code. Tokens per
//! field are documented inline.

use super::verb::{RefusalCategory, Verb};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

fn default_exits_listing_prefix() -> String {
    "Exits: ".to_string()
}

fn default_exits_none() -> String {
    "No obvious exits.".to_string()
}

/// The full set of templates the engine draws on. Placeholders are
/// `{item}`, `{npc}`, `{dir}`, `{keyword}`, `{room}`, `{rule}`, depending
/// on the field. Fields without any placeholder are literal strings.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VerbResponses {
    // ---- Choice-menu labels --------------------------------------------
    /// Template: `{dir}`.
    pub choice_go: String,
    /// Template: `{item}`.
    pub choice_take: String,
    /// Template: `{item}`.
    pub choice_examine_item: String,
    /// Template: `{item}`.
    pub choice_use: String,
    /// Template: `{item}`.
    pub choice_read: String,
    /// Template: `{item}`.
    pub choice_drop: String,
    /// Template: `{entity}`. Used for characters (NPCs).
    pub choice_talk: String,
    /// Template: `{entity}`. Used for objects (fixtures).
    pub choice_open_object: String,
    /// Template: `{entity}`. Used for any entity — character or object.
    pub choice_examine_entity: String,
    /// Template: `{keyword}`.
    pub choice_examine_feature: String,
    pub choice_look: String,
    pub choice_inventory: String,
    pub choice_wait: String,
    pub choice_leave_dialogue: String,
    /// Fallback when a passable-when-locked exit has no `locked_message`.
    pub exit_locked_default: String,

    // ---- Room description ----------------------------------------------
    /// Template: `{name}`.
    pub room_header: String,
    /// Prefix preceding a comma-joined list of visible entities.
    /// Combined string shape: `"{visible_listing_prefix}X, Y, Z."`
    pub visible_listing_prefix: String,
    /// Prefix preceding a comma-joined list of available exit directions.
    /// Combined string shape: `"{exits_listing_prefix}north, south, up."`
    #[serde(default = "default_exits_listing_prefix")]
    pub exits_listing_prefix: String,
    /// Shown in place of the exits listing when the room has no visible
    /// exits (or all exits are hidden by `visible_when` conditions).
    #[serde(default = "default_exits_none")]
    pub exits_none: String,

    // ---- Verb responses ------------------------------------------------
    pub inventory_empty: String,
    /// Prefix preceding a comma-joined list of inventory items.
    pub inventory_listing_prefix: String,
    /// Template: `{item}`.
    pub take_success: String,
    /// Template: `{item}`.
    pub take_already_carrying: String,
    /// Template: `{item}`.
    pub take_not_takeable: String,
    /// Template: `{item}`.
    pub drop_success: String,
    /// Template: `{item}`.
    pub use_not_carrying: String,
    /// Template: `{item}`.
    pub read_nothing_written: String,
    pub examine_unknown: String,
    /// Template: `{entity}`. Fires when the player opens an entity
    /// (character or object) that has no dialogue attached.
    pub npc_silent: String,
    pub leave_dialogue: String,
    pub wait: String,
    /// Fallback dialogue speaker label if no NPC references the dialogue.
    pub dialogue_default_speaker: String,

    // ---- Meta ----------------------------------------------------------
    pub option_unavailable: String,
    pub action_forbidden: String,
    /// Template: `{rule}`. Emitted only when rule tracing is enabled.
    pub trace_prefix: String,

    // ---- Verb refusals -------------------------------------------------
    //
    // When the parser recognises a verb but the current menu has no
    // target for it (and no examine fallback matched), it emits one of
    // these lines instead of a flat "You can't do that here." Each
    // template may reference `{noun}` (the raw token the player typed).
    /// Catch-all — used for verbs not in any of the categories below.
    pub refusal_default: String,
    /// `taste {noun}`, `lick {noun}`.
    pub refusal_taste: String,
    /// `eat {noun}`, `drink {noun}`.
    pub refusal_consume: String,
    /// `attack`, `hit`, `kick`, `break`, `smash`, `cut`, `burn`, `punch`,
    /// `strike`, `kill`, `fight`.
    pub refusal_violence: String,
    /// `kiss {noun}`, `apologise (to {noun})`.
    pub refusal_affection: String,
    /// `open`, `close`, `push`, `pull`, `turn`, `squeeze`, `rub`,
    /// `wave`, `swing`, `wear`, `remove`, `insert`, `fill`, `lock`,
    /// `unlock`, `switch`, `tie`. The item doesn't accept the action.
    pub refusal_manipulation: String,
    /// Bare `jump`, `jump {noun}`.
    pub refusal_jump: String,
    /// `give`, `show`, `buy`. Multi-object or commerce verbs we don't
    /// model.
    pub refusal_exchange: String,
    /// `wake`, `rouse`.
    pub refusal_wake: String,

    /// Per-verb overrides for the refusal category. Any verb absent from
    /// this map uses [`Verb::default_refusal_category`]. This is the
    /// author-facing knob for re-routing — no parser edit needed to make
    /// `Verb::Throw` refuse as violence instead of manipulation.
    #[serde(default)]
    pub refusal_overrides: HashMap<Verb, RefusalCategory>,
}

impl Default for VerbResponses {
    fn default() -> Self {
        Self {
            choice_go: "Go {dir}".to_string(),
            choice_take: "Take the {item}".to_string(),
            choice_examine_item: "Examine the {item}".to_string(),
            choice_use: "Use the {item}".to_string(),
            choice_read: "Read the {item}".to_string(),
            choice_drop: "Drop the {item}".to_string(),
            choice_talk: "Talk to {entity}".to_string(),
            choice_open_object: "Open {entity}".to_string(),
            choice_examine_entity: "Examine {entity}".to_string(),
            choice_examine_feature: "Look at the {keyword}".to_string(),
            choice_look: "Look".to_string(),
            choice_inventory: "Check inventory".to_string(),
            choice_wait: "Wait".to_string(),
            choice_leave_dialogue: "Leave the conversation".to_string(),
            exit_locked_default: "locked.".to_string(),

            room_header: "--- {name} ---".to_string(),
            visible_listing_prefix: "You see: ".to_string(),
            exits_listing_prefix: default_exits_listing_prefix(),
            exits_none: default_exits_none(),

            inventory_empty: "You are carrying nothing.".to_string(),
            inventory_listing_prefix: "You are carrying: ".to_string(),
            take_success: "You take the {item}.".to_string(),
            take_already_carrying: "You are already carrying the {item}.".to_string(),
            take_not_takeable: "The {item} won't move.".to_string(),
            drop_success: "You drop the {item}.".to_string(),
            use_not_carrying: "You aren't carrying the {item}.".to_string(),
            read_nothing_written: "There is nothing to read on the {item}.".to_string(),
            examine_unknown: "You see nothing special about that.".to_string(),
            npc_silent: "{entity} has nothing to say.".to_string(),
            leave_dialogue: "You step away from the conversation.".to_string(),
            wait: "Time drifts past.".to_string(),
            dialogue_default_speaker: "Voice".to_string(),

            option_unavailable: "That option is not available.".to_string(),
            action_forbidden: "You cannot do that.".to_string(),
            trace_prefix: "[trace] rule '{rule}' fired".to_string(),

            refusal_default: "Nothing happens.".to_string(),
            refusal_taste: "That wouldn't taste of anything worth knowing.".to_string(),
            refusal_consume: "That isn't food or drink.".to_string(),
            refusal_violence: "Violence won't help you here.".to_string(),
            refusal_affection: "That would be out of place.".to_string(),
            refusal_manipulation: "It doesn't yield.".to_string(),
            refusal_jump: "You hop on the spot. Nothing changes.".to_string(),
            refusal_exchange: "There's no one here to accept that.".to_string(),
            refusal_wake: "You are already awake.".to_string(),
            refusal_overrides: HashMap::new(),
        }
    }
}

impl VerbResponses {
    /// Substitute every occurrence of each [`Placeholder`] token in
    /// `template` with the paired value. Missing placeholders are left
    /// alone — useful if an author wants to embed literal braces.
    pub fn render(template: &str, replacements: &[(Placeholder, &str)]) -> String {
        let mut output = template.to_string();
        for (placeholder, value) in replacements {
            output = output.replace(placeholder.token(), value);
        }
        output
    }

    /// Resolve a verb's refusal category, consulting
    /// [`Self::refusal_overrides`] first and falling back to
    /// [`Verb::default_refusal_category`].
    pub fn refusal_category(&self, verb: Verb) -> RefusalCategory {
        self.refusal_overrides
            .get(&verb)
            .copied()
            .unwrap_or_else(|| verb.default_refusal_category())
    }

    /// Convenience builder: route `verb` to `category` on refusal.
    pub fn override_refusal(mut self, verb: Verb, category: RefusalCategory) -> Self {
        self.refusal_overrides.insert(verb, category);
        self
    }
}

/// Every placeholder name a [`VerbResponses`] template may contain. Using
/// this enum at call sites guarantees we can't mistype a token like
/// `"itme"` or use one that the struct docs don't advertise.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Placeholder {
    /// `{item}` — an item's display name.
    Item,
    /// `{entity}` — the display name of an entity (character or
    /// object). Replaces the old `{npc}` and `{fixture}` tokens.
    Entity,
    /// `{dir}` — an exit's direction label.
    Dir,
    /// `{keyword}` — a room's examine-feature keyword.
    Keyword,
    /// `{name}` — a room's display name.
    Name,
    /// `{noun}` — the raw noun the player typed, after article/preposition
    /// stripping. Used in verb-refusal templates.
    Noun,
    /// `{rule}` — a rule's ID.
    Rule,
    /// `{items}` — a comma-joined list of item names.
    Items,
    /// `{things}` — a comma-joined list of visible entities.
    Things,
}

impl Placeholder {
    /// The literal `{name}` token this placeholder replaces.
    pub const fn token(self) -> &'static str {
        match self {
            Placeholder::Item => "{item}",
            Placeholder::Entity => "{entity}",
            Placeholder::Dir => "{dir}",
            Placeholder::Keyword => "{keyword}",
            Placeholder::Name => "{name}",
            Placeholder::Noun => "{noun}",
            Placeholder::Rule => "{rule}",
            Placeholder::Items => "{items}",
            Placeholder::Things => "{things}",
        }
    }
}