Skip to main content

shuyi_core/
made.rs

1//! What made a record or a save: the engine's version, the game's word
2//! for its build, and a fingerprint of the content it ran on ([`Made`]).
3//! A record played back by another build can play differently without a
4//! bug in either; its stamp tells that apart from a desync.
5//!
6//! A game stamps its own save or record with a `made: Made` field, filled
7//! by [`Made::new`] when it is written (the fingerprint is the shipped
8//! bundle's, `shuyi_assets::bundle::Bundle::fingerprint`, computed once
9//! and kept). After reading one back it calls [`Made::check`] with this
10//! build's stamp: what to do with a [`Mismatch`] is the game's (play on,
11//! warn, refuse). A field `#[serde(default)]` of `Option<Made>` keeps
12//! older files without one readable.
13//!
14//! The engine's version ([`ENGINE_VERSION`]) moves only at a release. A
15//! game pinned to a git revision of the engine that wants that revision
16//! in its stamp puts it in the game's word (`"my-game 1.2.0 engine
17//! 3f2a1c9"`).
18
19use alloc::string::{String, ToString};
20use alloc::vec::Vec;
21use core::fmt;
22use core::str::FromStr;
23
24use serde::de::{self, Deserializer, Visitor};
25use serde::{Deserialize, Serialize, Serializer};
26
27/// The engine's version: every `shuyi-*` crate has the workspace's.
28pub const ENGINE_VERSION: &str = env!("CARGO_PKG_VERSION");
29
30/// A fingerprint of a game's content: the same bytes give the same one on
31/// every platform. Shown, and written to a file, as sixteen lowercase hex
32/// digits, as a `StateHash` is shown (a string, since
33/// JSON can't hold every `u64`).
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
35#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
36pub struct ContentHash(
37    /// The hash.
38    #[cfg_attr(feature = "schema", schemars(with = "String"))]
39    pub u64,
40);
41
42impl fmt::Display for ContentHash {
43    /// Sixteen lowercase hex digits.
44    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
45        write!(f, "{:016x}", self.0)
46    }
47}
48
49/// Text that isn't a [`ContentHash`]: sixteen hex digits.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct NotAHash(pub String);
52
53impl fmt::Display for NotAHash {
54    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
55        write!(
56            f,
57            "\"{}\" is not a content hash (sixteen hex digits)",
58            self.0
59        )
60    }
61}
62
63impl FromStr for ContentHash {
64    type Err = NotAHash;
65
66    /// Sixteen hex digits, as [`Display`](fmt::Display) writes them.
67    fn from_str(text: &str) -> Result<Self, NotAHash> {
68        let digits = text.len() == 16 && text.bytes().all(|b| b.is_ascii_hexdigit());
69        digits
70            .then(|| u64::from_str_radix(text, 16).ok())
71            .flatten()
72            .map(Self)
73            .ok_or_else(|| NotAHash(text.to_string()))
74    }
75}
76
77impl Serialize for ContentHash {
78    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
79        serializer.collect_str(self)
80    }
81}
82
83impl<'de> Deserialize<'de> for ContentHash {
84    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
85        /// Reads the hash from its text.
86        struct Text;
87
88        impl Visitor<'_> for Text {
89            type Value = ContentHash;
90
91            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92                f.write_str("sixteen hex digits")
93            }
94
95            fn visit_str<E: de::Error>(self, text: &str) -> Result<ContentHash, E> {
96                text.parse().map_err(E::custom)
97            }
98        }
99
100        deserializer.deserialize_str(Text)
101    }
102}
103
104/// What made a record or a save: the engine, the game's build and the
105/// content it ran on, as the module docs say.
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
108#[serde(deny_unknown_fields)]
109// A stamp: built with `Made::new`, it may grow (ADR-0039).
110#[non_exhaustive]
111pub struct Made {
112    /// The engine's version ([`ENGINE_VERSION`]).
113    pub engine: String,
114    /// The game's word for its build (its name and version, say), as a
115    /// pack's stamp is.
116    pub game: String,
117    /// The fingerprint of the content it ran on.
118    pub content: ContentHash,
119}
120
121impl Made {
122    /// The stamp of this engine, the game's build `game` and its content
123    /// `content`.
124    pub fn new(game: impl Into<String>, content: ContentHash) -> Self {
125        Self {
126            engine: ENGINE_VERSION.to_string(),
127            game: game.into(),
128            content,
129        }
130    }
131
132    /// Whether this stamp is `now`'s, this build's; otherwise each part
133    /// that differs.
134    pub fn check(&self, now: &Made) -> Result<(), Mismatch> {
135        let parts: Vec<Differs> = [
136            (Part::Engine, self.engine.clone(), now.engine.clone()),
137            (Part::Game, self.game.clone(), now.game.clone()),
138            (
139                Part::Content,
140                self.content.to_string(),
141                now.content.to_string(),
142            ),
143        ]
144        .into_iter()
145        .filter(|(_, made, now)| made != now)
146        .map(|(part, made, now)| Differs { part, made, now })
147        .collect();
148        if parts.is_empty() {
149            Ok(())
150        } else {
151            Err(Mismatch { parts })
152        }
153    }
154}
155
156impl fmt::Display for Made {
157    /// `engine 0.1.0, game "demo 1.0", content 1a2b3c4d5e6f7a8b`.
158    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
159        write!(
160            f,
161            "engine {}, game \"{}\", content {}",
162            self.engine, self.game, self.content
163        )
164    }
165}
166
167/// A part of a [`Made`].
168#[derive(Debug, Clone, Copy, PartialEq, Eq)]
169pub enum Part {
170    /// The engine's version.
171    Engine,
172    /// The game's word for its build.
173    Game,
174    /// The content's fingerprint.
175    Content,
176}
177
178impl Part {
179    /// Its name: `engine`, `game` or `content`.
180    pub fn name(self) -> &'static str {
181        match self {
182            Part::Engine => "engine",
183            Part::Game => "game",
184            Part::Content => "content",
185        }
186    }
187}
188
189/// One part that differs between two stamps.
190#[derive(Debug, Clone, PartialEq, Eq)]
191#[non_exhaustive]
192pub struct Differs {
193    /// Which part.
194    pub part: Part,
195    /// Its value in the stamp checked.
196    pub made: String,
197    /// Its value in this build's.
198    pub now: String,
199}
200
201/// How a stamp differs from this build's ([`Made::check`]): each part
202/// that differs, in the order engine, game, content. Shown as `made with
203/// engine 0.1.0, content 1a2b3c4d5e6f7a8b; this build: engine 0.2.0,
204/// content 3c4d5e6f7a8b9c0d`.
205#[derive(Debug, Clone, PartialEq, Eq)]
206#[non_exhaustive]
207pub struct Mismatch {
208    /// The parts that differ: at least one.
209    pub parts: Vec<Differs>,
210}
211
212impl Mismatch {
213    /// Each part as `name value`, the value `made` or `now` picks.
214    fn side(&self, f: &mut fmt::Formatter<'_>, value: fn(&Differs) -> &str) -> fmt::Result {
215        for (i, differs) in self.parts.iter().enumerate() {
216            let comma = if i == 0 { "" } else { ", " };
217            let value = value(differs);
218            match differs.part {
219                Part::Game => write!(f, "{comma}game \"{value}\"")?,
220                part => write!(f, "{comma}{} {value}", part.name())?,
221            }
222        }
223        Ok(())
224    }
225}
226
227impl fmt::Display for Mismatch {
228    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
229        f.write_str("made with ")?;
230        self.side(f, |d| &d.made)?;
231        f.write_str("; this build: ")?;
232        self.side(f, |d| &d.now)
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use alloc::format;
239
240    use super::*;
241
242    fn made(engine: &str, game: &str, content: u64) -> Made {
243        Made {
244            engine: engine.into(),
245            game: game.into(),
246            content: ContentHash(content),
247        }
248    }
249
250    #[test]
251    fn a_new_stamp_names_this_engine() {
252        let stamp = Made::new("demo 1.0", ContentHash(7));
253        assert_eq!(stamp, made(ENGINE_VERSION, "demo 1.0", 7));
254        assert_eq!(ENGINE_VERSION, env!("CARGO_PKG_VERSION"));
255        assert_eq!(stamp.check(&stamp.clone()), Ok(()));
256    }
257
258    #[test]
259    fn a_check_names_each_part_that_differs_with_both_values() {
260        let now = made("0.2.0", "demo 1.0", 0x3c4d_5e6f_7a8b_9c0d);
261        let engine = made("0.1.0", "demo 1.0", 0x3c4d_5e6f_7a8b_9c0d)
262            .check(&now)
263            .unwrap_err();
264        assert_eq!(
265            engine.parts,
266            [Differs {
267                part: Part::Engine,
268                made: "0.1.0".into(),
269                now: "0.2.0".into()
270            }]
271        );
272        assert_eq!(
273            engine.to_string(),
274            "made with engine 0.1.0; this build: engine 0.2.0"
275        );
276        let game = made("0.2.0", "demo 0.9", 0x3c4d_5e6f_7a8b_9c0d)
277            .check(&now)
278            .unwrap_err();
279        assert_eq!(
280            game.to_string(),
281            "made with game \"demo 0.9\"; this build: game \"demo 1.0\""
282        );
283        let content = made("0.2.0", "demo 1.0", 1).check(&now).unwrap_err();
284        assert_eq!(content.parts[0].part, Part::Content);
285        assert_eq!(
286            content.to_string(),
287            "made with content 0000000000000001; this build: content 3c4d5e6f7a8b9c0d"
288        );
289        let two = made("0.1.0", "demo 1.0", 0x1a2b_3c4d_5e6f_7a8b)
290            .check(&now)
291            .unwrap_err();
292        assert_eq!(
293            two.to_string(),
294            "made with engine 0.1.0, content 1a2b3c4d5e6f7a8b; this build: engine 0.2.0, \
295             content 3c4d5e6f7a8b9c0d"
296        );
297        let all = made("0.1.0", "x", 1).check(&now).unwrap_err();
298        let parts: Vec<&str> = all.parts.iter().map(|d| d.part.name()).collect();
299        assert_eq!(parts, ["engine", "game", "content"]);
300    }
301
302    #[test]
303    fn a_content_hash_reads_back_from_its_sixteen_hex_digits() {
304        let hash = ContentHash(0x00ab_cdef_0123_4567);
305        assert_eq!(hash.to_string(), "00abcdef01234567");
306        assert_eq!("00abcdef01234567".parse(), Ok(hash));
307        assert_eq!("00ABCDEF01234567".parse(), Ok(hash));
308        assert_eq!(format!("{}", ContentHash(u64::MAX)), "ffffffffffffffff");
309        for bad in [
310            "",
311            "abc",
312            "00abcdef012345678",
313            "+0abcdef01234567",
314            "00abcdef0123456g",
315        ] {
316            let error = bad.parse::<ContentHash>().unwrap_err();
317            assert_eq!(error, NotAHash(bad.into()));
318        }
319        assert_eq!(
320            NotAHash("x".into()).to_string(),
321            "\"x\" is not a content hash (sixteen hex digits)"
322        );
323    }
324
325    #[test]
326    fn a_stamp_is_written_with_its_hash_as_text_and_read_back() {
327        let stamp = made("0.1.0", "demo 1.0", 0x1a2b_3c4d_5e6f_7a8b);
328        let text = ron::to_string(&stamp).unwrap();
329        assert_eq!(
330            text,
331            "(engine:\"0.1.0\",game:\"demo 1.0\",content:\"1a2b3c4d5e6f7a8b\")"
332        );
333        assert_eq!(ron::from_str::<Made>(&text).unwrap(), stamp);
334        assert_eq!(
335            stamp.to_string(),
336            "engine 0.1.0, game \"demo 1.0\", content 1a2b3c4d5e6f7a8b"
337        );
338        let bad = ron::from_str::<ContentHash>("\"1a2b\"").unwrap_err();
339        assert!(bad.to_string().contains("not a content hash"), "{bad}");
340        assert!(ron::from_str::<ContentHash>("12").is_err());
341        // A value of another type says what a hash is.
342        let number: Result<ContentHash, serde::de::value::Error> =
343            ContentHash::deserialize(serde::de::IntoDeserializer::into_deserializer(12_u64));
344        let error = number.unwrap_err().to_string();
345        assert!(error.ends_with("expected sixteen hex digits"), "{error}");
346    }
347}