multilinear-story 0.1.3

Game-facing story runtime over a multilinear net: presentation wiring and owner-routed callable events
Documentation
#![deny(missing_docs)]

//! Game-facing story runtime over a [`multilinear`] net.
//!
//! Loads a multilinear net (`.mld`: aspect defaults before the first header, then events)
//! together with a presentation *wiring* file, then routes the currently callable events to
//! their owners and reads aspect state. This is the shared substrate behind the "callable-events-as-options"
//! pattern: an interactable's options are the callable events wired to it, applying one is a
//! transition, and the net re-derives availability on its own.
//!
//! ```text
//! # events
//! ## bibo talk
//! owner bibo
//! dialog bibo_talk
//!
//! # idle
//! ## bibo
//! dialog bibo_idle
//! ```
//!
//! The wiring is header-based (via `header-parsing`): event names are header segments, so they
//! may contain whitespace. Each wired event must exist in the net or loading fails.

use std::{collections::HashMap, convert::Infallible, io::Error as IoError, path::Path};

use event_simulation::{Simulation, SimulationInfo, SimulationState};
use header_parsing::parse_header;
use multilinear::{MultilinearInfo, MultilinearSimulation};
use multilinear_parser::{NamedMultilinearInfo, parse_multilinear, parse_multilinear_extended};
use thiserror::Error;

pub use multilinear::{Aspect, Event};

/// The presentation wired to an event: which interactable surfaces it, and which dialog to play.
pub struct Presentation {
    /// The owner that surfaces this event (an NPC id, a marker, a place).
    pub owner: Box<str>,
    /// The dialog key to play when this event is chosen.
    pub dialog: Box<str>,
}

/// An error while loading a story.
#[derive(Debug, Error)]
pub enum LoadError {
    /// The net (`.mld`/`.mla`) failed to parse.
    #[error("failed to parse story net: {0}")]
    Net(String),
    /// The wiring file could not be read.
    #[error("failed to read wiring file: {0}")]
    Wiring(#[from] IoError),
    /// The wiring references an event that does not exist in the net.
    #[error("wiring references unknown event: {0}")]
    UnknownEvent(Box<str>),
}

/// A loaded story: the net simulation plus its name maps and presentation wiring.
pub struct Story {
    simulation: MultilinearSimulation,
    events: HashMap<Box<str>, Event>,
    aspects: HashMap<Box<str>, (Aspect, Vec<Box<str>>)>,
    presentation: HashMap<Event, Presentation>,
    idle: HashMap<Box<str>, Box<str>>,
}

impl Story {
    /// Loads a story from a net (`.mld`, aspect defaults before the first header) and a wiring file.
    ///
    /// Fails if the net does not parse, the wiring cannot be read, or the wiring names an event
    /// the net does not define. Events in the net without wiring are reported as warnings (they
    /// are reachable but never surfaced) and left out of the presentation map.
    pub fn load(net: &Path, wiring: &Path) -> Result<Self, LoadError> {
        let named = parse_multilinear_extended(net, None)
            .map_err(|error| LoadError::Net(error.to_string()))?;
        let wiring = parse_wiring(wiring)?;
        Self::from_parts(named, wiring)
    }

    /// Loads a story like [`load`](Self::load), but restores a previously saved
    /// aspect state (from [`state`](Self::state)) instead of starting fresh.
    ///
    /// The state is the raw aspect value indices; a length or version mismatch
    /// with the current net yields an inconsistent story rather than an error,
    /// so only restore state saved against the same net.
    pub fn load_with_state(
        net: &Path,
        wiring: &Path,
        state: Vec<usize>,
    ) -> Result<Self, LoadError> {
        let named = parse_multilinear_extended(net, None)
            .map_err(|error| LoadError::Net(error.to_string()))?;
        let wiring = parse_wiring(wiring)?;
        Self::from_parts_with_state(named, wiring, Some(state))
    }

    /// Returns the current aspect state as raw value indices, for saving.
    ///
    /// Restore it with [`load_with_state`](Self::load_with_state) against the
    /// same net.
    pub fn state(&self) -> Vec<usize> {
        self.simulation.data().to_vec()
    }

    /// Loads a story from in-memory net and wiring text.
    ///
    /// The string equivalent of [`load`](Self::load), for embedded or fetched
    /// content (e.g. on `wasm32` where there is no filesystem). `net` is the
    /// `.mld` net source; `wiring` is the wiring file source.
    pub fn load_from_str(net: &str, wiring: &str) -> Result<Self, LoadError> {
        let named =
            parse_multilinear(net.as_bytes()).map_err(|error| LoadError::Net(error.to_string()))?;
        Self::from_parts(named, parse_wiring_str(wiring))
    }

    fn from_parts(named: NamedMultilinearInfo, wiring: Wiring) -> Result<Self, LoadError> {
        Self::from_parts_with_state(named, wiring, None)
    }

    fn from_parts_with_state(
        named: NamedMultilinearInfo,
        wiring: Wiring,
        state: Option<Vec<usize>>,
    ) -> Result<Self, LoadError> {
        let events: HashMap<Box<str>, Event> = (&named.events)
            .into_iter()
            .map(|(event, namespace)| (join_namespace(namespace), event))
            .collect();

        let aspects: HashMap<Box<str>, (Aspect, Vec<Box<str>>)> = (&named.aspects)
            .into_iter()
            .map(|(aspect, (name, values))| (name.clone(), (aspect, values.clone())))
            .collect();

        let Wiring {
            events: wired,
            idle,
        } = wiring;

        let mut presentation = HashMap::new();
        for (name, entry) in wired {
            let Some(&event) = events.get(&name) else {
                return Err(LoadError::UnknownEvent(name));
            };
            presentation.insert(event, entry);
        }

        for (name, event) in &events {
            if !presentation.contains_key(event) {
                eprintln!("warning: net event has no wiring (never surfaced): {name}");
            }
        }

        let simulation = match state {
            Some(data) => MultilinearSimulation::from_data(named.info, data)
                .unwrap_or_else(|error: Infallible| match error {}),
            None => MultilinearSimulation::new(named.info),
        };

        Ok(Self {
            simulation,
            events,
            aspects,
            presentation,
            idle,
        })
    }

    /// An empty story, for use as a fallback when loading fails.
    pub fn empty() -> Self {
        Self {
            simulation: MultilinearSimulation::new(MultilinearInfo::default()),
            events: HashMap::new(),
            aspects: HashMap::new(),
            presentation: HashMap::new(),
            idle: HashMap::new(),
        }
    }

    /// Returns the event of the given name, if it exists.
    pub fn event(&self, name: &str) -> Option<Event> {
        self.events.get(name).copied()
    }

    /// Iterates over the aspects and their names.
    pub fn aspects(&self) -> impl Iterator<Item = (&str, Aspect)> {
        self.aspects
            .iter()
            .map(|(name, (aspect, _))| (name.as_ref(), *aspect))
    }

    /// Returns the aspect of the given name, if it exists.
    pub fn aspect(&self, name: &str) -> Option<Aspect> {
        self.aspects.get(name).map(|(aspect, _)| *aspect)
    }

    /// Returns the current value index of an aspect.
    pub fn value(&self, aspect: Aspect) -> usize {
        self.simulation.data()[aspect.0]
    }

    /// Returns the current value name of an aspect, looked up by aspect name.
    pub fn value_name(&self, name: &str) -> Option<&str> {
        let (aspect, values) = self.aspects.get(name)?;
        values
            .get(self.simulation.data()[aspect.0])
            .map(AsRef::as_ref)
    }

    /// Returns the value names an aspect can take, in index order.
    pub fn values(&self, name: &str) -> Option<impl Iterator<Item = &str>> {
        let (_, values) = self.aspects.get(name)?;
        Some(values.iter().map(AsRef::as_ref))
    }

    /// Sets an aspect to one of its values by name, bypassing the events that
    /// would normally reach it.
    ///
    /// Returns whether the aspect and the value exist. Intended for debugging,
    /// testing and starting a session from a chosen situation; regular play
    /// should go through [`play`](Self::play).
    pub fn set_value(&mut self, name: &str, value: &str) -> bool {
        let Some((aspect, values)) = self.aspects.get(name) else {
            return false;
        };
        let Some(index) = values
            .iter()
            .position(|candidate| candidate.as_ref() == value)
        else {
            return false;
        };

        let mut data = self.simulation.data().to_vec();
        data[aspect.0] = index;
        let state = self
            .simulation
            .load_state(data)
            .unwrap_or_else(|error: Infallible| match error {});
        self.simulation.state = state;
        true
    }

    /// Whether an event is currently callable.
    pub fn callable(&self, event: Event) -> bool {
        self.simulation.callable(event)
    }

    /// All currently callable events, sorted.
    pub fn callable_events(&self) -> Vec<Event> {
        let mut events: Vec<Event> = self.simulation.callables().collect();
        events.sort();
        events
    }

    /// The callable events wired to the given owner, sorted — the owner's current options.
    pub fn options(&self, owner: &str) -> Vec<Event> {
        let mut events: Vec<Event> = self
            .simulation
            .callables()
            .filter(|event| {
                self.presentation
                    .get(event)
                    .is_some_and(|presentation| presentation.owner.as_ref() == owner)
            })
            .collect();
        events.sort();
        events
    }

    /// The presentation wired to an event, if any.
    pub fn presentation(&self, event: Event) -> Option<&Presentation> {
        self.presentation.get(&event)
    }

    /// The dialog key wired to an event, if any.
    pub fn dialog(&self, event: Event) -> Option<&str> {
        self.presentation.get(&event).map(|p| p.dialog.as_ref())
    }

    /// The owner wired to an event, if any.
    pub fn owner(&self, event: Event) -> Option<&str> {
        self.presentation.get(&event).map(|p| p.owner.as_ref())
    }

    /// The idle dialog for an owner with no callable options, if wired.
    pub fn idle(&self, owner: &str) -> Option<&str> {
        self.idle.get(owner).map(AsRef::as_ref)
    }

    /// Applies an event, advancing the net. Returns whether it was callable.
    pub fn play(&mut self, event: Event) -> bool {
        self.simulation.try_call(event)
    }

    /// Reverts an event, undoing its transition. Returns whether it was revertable.
    pub fn revert(&mut self, event: Event) -> bool {
        self.simulation.try_revert(event)
    }
}

fn join_namespace(namespace: &[Box<str>]) -> Box<str> {
    namespace
        .iter()
        .map(AsRef::as_ref)
        .collect::<Vec<&str>>()
        .join(" ")
        .into_boxed_str()
}

struct Wiring {
    events: HashMap<Box<str>, Presentation>,
    idle: HashMap<Box<str>, Box<str>>,
}

fn parse_wiring(path: &Path) -> Result<Wiring, IoError> {
    let content = std::fs::read_to_string(path)?;
    Ok(parse_wiring_str(&content))
}

fn parse_wiring_str(content: &str) -> Wiring {
    let mut events: HashMap<Box<str>, Presentation> = HashMap::new();
    let mut idle: HashMap<Box<str>, Box<str>> = HashMap::new();
    let mut header_path: Vec<Box<str>> = Vec::new();

    for line in content.lines() {
        if let Some(result) = parse_header(&mut header_path, line) {
            match result {
                Ok(changes) => {
                    changes.apply();
                }
                Err(_) => eprintln!("invalid header in wiring: {line}"),
            }
            continue;
        }

        let trimmed = line.trim();
        if trimmed.is_empty() {
            continue;
        }

        let mut tokens = trimmed.splitn(2, char::is_whitespace);
        let Some(key) = tokens.next() else { continue };
        let value = tokens.next().unwrap_or("").trim();

        match header_path.as_slice() {
            [section, event] if section.as_ref() == "events" => {
                let entry = events.entry(event.clone()).or_insert_with(|| Presentation {
                    owner: Box::from(""),
                    dialog: Box::from(""),
                });
                match key {
                    "owner" => entry.owner = Box::from(value),
                    "dialog" => entry.dialog = Box::from(value),
                    _ => {}
                }
            }
            [section, owner] if section.as_ref() == "idle" && key == "dialog" => {
                idle.insert(owner.clone(), Box::from(value));
            }
            _ => {}
        }
    }

    Wiring { events, idle }
}