mirage-engine 0.1.1

Mirage, an immediate-mode 3D engine for simple games on desktop and the browser
Documentation
//! Every value a game keeps between runs: what an earlier run stored, and
//! whatever this one has saved over it.
//!
//! A game names its save keys as one vocabulary through
//! `#[derive(Saves)]`, and reads and writes them with
//! [`saved`](crate::FrameContext::saved) and
//! [`save`](crate::FrameContext::save). A read always returns: the stored
//! value where a run stored one, and the key's own
//! [`fallback`](crate::SaveKey::fallback) where none did, so a first run
//! needs no pass that stores the defaults. A value the store kept that no
//! longer reads as the key's value returns that fallback too, with a debug
//! log.
//!
//! A vocabulary holds one of four kinds of value — a whole number, a
//! number, a flag, or text — and every key in it keeps that kind; keys of
//! another kind need a vocabulary of their own.
//!
//! Writes are kept through a frame's ticks and its own calls, and stored
//! once the frame ends, and only where something changed. The store is the
//! platform's own, per title: the file beside the one the bindings are
//! kept in on the desktop, and the browser's own storage on the web. A
//! game run through the offscreen session keeps nothing and reads the
//! fallbacks. Nothing drops a key; a game writes the value the next read
//! should return.

use std::collections::BTreeMap;

use crate::platform::Store;

use sealed::Kept;

/// The store's first line; text that does not start with it loads nothing.
const HEADER: &str = "mirage-engine saves 1";

/// Save key value types: `bool`, `i64`, `f64` or `String`, and nothing
/// else.
///
/// A game's own type is kept as one of these: an enum as a number, a whole
/// state as text.
pub trait SaveValue: Kept {}

/// Trait every save vocabulary implements, written by
/// [`Saves`](macro@crate::Saves).
///
/// Required if you want a vocabulary of keys; what each of them keeps is
/// declared by hand, in [`SaveKey`].
pub trait Saves {
    /// Name the store keeps this key under, made of the vocabulary's name
    /// and this key's own name.
    fn name(&self) -> &'static str;
}

/// One thing a game keeps between runs, named by a vocabulary of its own.
///
/// Required if you want [`save`](crate::FrameContext::save) and
/// [`saved`](crate::FrameContext::saved) to take a key: they read and keep its
/// [`Value`](Self::Value) and nothing else. Every key of a vocabulary
/// keeps the same kind of value; keys of another kind need a vocabulary of
/// their own.
pub trait SaveKey: Saves {
    /// The value this key keeps, of the four a store takes.
    type Value: SaveValue;

    /// Value this key reads as before any run has saved it. Saving this
    /// value again leaves the key at the same fallback.
    fn fallback(&self) -> Self::Value;
}

/// Every entry this run reads through: what the store kept of the runs
/// before it, and whatever this one has saved since.
pub(crate) struct Saved {
    /// By name, in an order the store writes the same way twice.
    entries: BTreeMap<String, String>,
    dirty: bool,
    store: Store,
}

impl Saved {
    /// State a run starts with: whatever `store` kept, which every key of
    /// this run reads through.
    pub(crate) fn new(store: Store) -> Self {
        Self {
            entries: entries(store.read().as_deref()),
            dirty: false,
            store,
        }
    }

    /// Value last saved under `key`; falls back to `key.fallback()` if no
    /// run has saved it, or if what was saved no longer reads back as
    /// this key's value.
    pub(crate) fn read<K: SaveKey>(&self, key: K) -> K::Value {
        let Some(kept) = self.entries.get(key.name()) else {
            return key.fallback();
        };
        K::Value::read(kept).unwrap_or_else(|| {
            log::debug!(
                "what the store kept for `{}` does not read as its value: {kept}",
                key.name()
            );
            key.fallback()
        })
    }

    /// Keeps `value` under `key`, and marks the store to be written where
    /// that is not what it already holds.
    pub(crate) fn write<K: SaveKey>(&mut self, key: K, value: K::Value) {
        let written = value.written();
        let kept = self.entries.get(key.name());
        if kept.is_some_and(|kept| *kept == written) {
            return;
        }
        self.entries.insert(key.name().to_owned(), written);
        self.dirty = true;
    }

    /// Writes the whole store where a value has changed since the last
    /// call, and nothing at all where none has.
    pub(crate) fn flush(&mut self) {
        if core::mem::take(&mut self.dirty) {
            self.store.write(&self.written());
        }
    }

    /// Every entry as the store keeps it, with the ones no key of this run
    /// names left in place.
    fn written(&self) -> String {
        let mut out = String::from(HEADER);
        out.push('\n');
        for (name, value) in &self.entries {
            escaped(name, &mut out);
            out.push(' ');
            out.push_str(value);
            out.push('\n');
        }
        out
    }
}

/// Every entry one store's text holds, or nothing where it is not text
/// this version wrote.
fn entries(text: Option<&str>) -> BTreeMap<String, String> {
    let Some(text) = text else {
        return BTreeMap::new();
    };

    let mut lines = text.lines();
    if lines.next().map(str::trim) != Some(HEADER) {
        log::debug!("what the store kept is not this version's; every key reads as its fallback");
        return BTreeMap::new();
    }

    lines
        .filter(|line| !line.is_empty())
        .filter_map(|line| {
            let read = line
                .split_once(' ')
                .and_then(|(name, value)| Some((unescaped(name)?, value.to_owned())));
            if read.is_none() {
                log::debug!("a kept entry was dropped: {line}");
            }
            read
        })
        .collect()
}

/// Writes `text` as one word, so that a name and a value keep whatever
/// spaces and line ends they were saved with.
fn escaped(text: &str, out: &mut String) {
    for letter in text.chars() {
        match letter {
            '\\' => out.push_str("\\\\"),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            ' ' => out.push_str("\\s"),
            _ => out.push(letter),
        }
    }
}

/// The text [`escaped`] wrote, or nothing where the word is not one it
/// could have written.
fn unescaped(text: &str) -> Option<String> {
    let mut out = String::with_capacity(text.len());
    let mut letters = text.chars();
    while let Some(letter) = letters.next() {
        match letter {
            '\\' => out.push(match letters.next()? {
                '\\' => '\\',
                'n' => '\n',
                'r' => '\r',
                's' => ' ',
                _ => return None,
            }),
            _ => out.push(letter),
        }
    }
    Some(out)
}

/// The values a store keeps as one word each, written and read back by the
/// standard types themselves.
macro_rules! parsed {
    ($($value:ty),*) => {$(
        impl Kept for $value {
            fn written(&self) -> String {
                self.to_string()
            }

            fn read(text: &str) -> Option<Self> {
                text.parse().ok()
            }
        }

        impl SaveValue for $value {}
    )*};
}

parsed!(bool, i64, f64);

impl Kept for String {
    fn written(&self) -> String {
        let mut out = String::with_capacity(self.len());
        escaped(self, &mut out);
        out
    }

    fn read(text: &str) -> Option<Self> {
        unescaped(text)
    }
}

impl SaveValue for String {}

/// Sealed: `Kept` is `pub` so code can name it, and this module is
/// `pub(crate)` so only this crate can implement it.
pub(crate) mod sealed {
    /// The store's write and read of one kind of value; the text is one
    /// word, so a line is a name and a value.
    pub trait Kept: Sized {
        fn written(&self) -> String;
        fn read(text: &str) -> Option<Self>;
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// A game's four vocabularies, one per kind of value it keeps.
    macro_rules! vocabulary {
        ($name:ident, $value:ty, $fallback:expr) => {
            #[derive(Clone, Copy)]
            struct $name;

            impl Saves for $name {
                fn name(&self) -> &'static str {
                    stringify!($name)
                }
            }

            impl SaveKey for $name {
                type Value = $value;

                fn fallback(&self) -> $value {
                    $fallback
                }
            }
        };
    }

    vocabulary!(Score, i64, 0);
    vocabulary!(Lap, f64, 0.0);
    vocabulary!(Seen, bool, false);
    vocabulary!(Player, String, "nobody".to_owned());

    /// Text with everything a line cannot hold as it is.
    fn awkward() -> String {
        "  two\nlines \\ and a tab\t ".to_owned()
    }

    fn saves(kept: Option<&str>) -> Saved {
        Saved {
            entries: entries(kept),
            dirty: false,
            store: Store::saves(None),
        }
    }

    #[test]
    fn every_kind_of_value_reads_back_as_it_was_saved() {
        let mut written = saves(None);
        written.write(Score, 120);
        written.write(Lap, -0.5);
        written.write(Seen, true);
        written.write(Player, awkward());

        let read = saves(Some(&written.written()));

        assert_eq!(read.read(Score), 120);
        assert_eq!(read.read(Lap), -0.5);
        assert!(read.read(Seen));
        assert_eq!(read.read(Player), awkward());
        assert_eq!(
            read.written(),
            written.written(),
            "and writes the same store again"
        );
    }

    #[test]
    fn a_key_no_run_kept_reads_as_its_fallback_and_what_was_kept_reads_over_it() {
        let mut written = saves(None);
        written.write(Score, 7);

        let read = saves(Some(&written.written()));

        assert_eq!(read.read(Score), 7);
        assert_eq!(read.read(Lap), 0.0, "which the store never kept");
        assert_eq!(read.read(Player), "nobody");
    }

    #[test]
    fn an_entry_no_key_of_this_run_names_is_carried_through_a_rewrite() {
        let kept = format!("{HEADER}\nScore 3\nFurthest\\sLevel 9\n");

        let mut read = saves(Some(&kept));
        read.write(Score, 4);

        assert_eq!(read.read(Score), 4);
        assert_eq!(
            read.written(),
            format!("{HEADER}\nFurthest\\sLevel 9\nScore 4\n"),
            "the entry this run knows nothing of is written back as it was"
        );
    }

    #[test]
    fn a_store_that_reads_as_nothing_leaves_every_fallback_standing() {
        for broken in [
            "",
            "nonsense",
            "mirage-engine saves 2\nScore 5\n",
            &format!("{HEADER}\nScore\n"),
            &format!("{HEADER}\nScore twelve\n"),
            &format!("{HEADER}\nScore\\q 5\n"),
        ] {
            let read = saves(Some(broken));
            assert_eq!(read.read(Score), 0, "`{broken}` left the fallback standing");
        }
    }

    #[test]
    fn saving_a_value_the_store_already_says_leaves_it_with_nothing_to_write() {
        let mut written = saves(None);
        written.write(Score, 42);
        assert!(written.dirty);

        let mut read = saves(Some(&written.written()));
        read.write(Score, 42);

        assert!(!read.dirty, "the same value again is not a change");
        read.write(Score, 43);
        assert!(read.dirty);
    }

    #[test]
    fn a_store_mangled_any_which_way_still_reads_as_one_this_run_can_use() {
        let mut written = saves(None);
        written.write(Score, 120);
        written.write(Player, awkward());
        let kept = written.written();

        for mangled in crate::platform::manglings(&kept) {
            let mut read = saves(Some(&mangled));
            read.write(Score, 7);

            assert_eq!(read.read(Score), 7, "over {mangled:?}");
            assert_eq!(
                saves(Some(&read.written())).written(),
                read.written(),
                "and what it writes reads back the same, over {mangled:?}"
            );
        }
    }

    #[test]
    fn a_run_with_no_title_keeps_nothing_and_still_reads_what_it_saved() {
        let mut saves = Saved::new(Store::saves(None));
        saves.write(Score, 5);
        saves.flush();

        assert_eq!(saves.read(Score), 5);
        assert_eq!(Saved::new(Store::saves(None)).read(Score), 0);
    }
}