Skip to main content

shuyi_cli/contract/
replay.rs

1//! `replay`: a record of a game re-run by the game's own rules, with a
2//! state hash per step ([`shuyi_core::state_hash`]). One record is run
3//! twice, which finds luck or state that a run doesn't own; two records
4//! are run side by side. The answer says the first step where the two
5//! runs' hashes differ, and passes if there is none.
6//!
7//! What a record is belongs to the game (for a turn game: its seed and
8//! its commands, ADR-0003 §2). [`turns`] re-runs one over any
9//! [`TurnRules`].
10//!
11//! A record stamped with what made it ([`ReplayRun::made`]) is checked
12//! against this build's stamp ([`GameTools::made`]) before anything is
13//! compared: one made by another engine, another build of the game or
14//! other content is refused, since its runs may differ with no desync,
15//! unless the input says [`any_stamp`](ReplayInput::any_stamp).
16
17use std::fmt::{Debug, Display, Write as _};
18use std::fs;
19use std::path::Path;
20
21use schemars::JsonSchema;
22use serde::{Deserialize, Serialize};
23use shuyi_core::{Made, TurnRules, state_hash};
24
25use super::codes::{self, Code};
26use super::{Answer, Op};
27use crate::game::{GameTools, code_of};
28
29/// What `replay` re-runs.
30#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
31#[serde(deny_unknown_fields)]
32// An input: built with `ReplayInput::new` (or `default()`) and its fields
33// assigned, it may grow (ADR-0039).
34#[non_exhaustive]
35pub struct ReplayInput {
36    /// The record's file, from the project's root.
37    pub record: String,
38    /// A second record to run beside it; without one, the record is run
39    /// twice.
40    #[serde(default)]
41    pub other: Option<String>,
42    /// Run a record stamped by another build or with other content too
43    /// (`--any-stamp`), which is otherwise refused; its runs may then
44    /// differ for that reason alone.
45    #[serde(default)]
46    pub any_stamp: bool,
47}
48
49impl ReplayInput {
50    /// The record in the file `record`, run twice (no
51    /// [`other`](Self::other)), refused if stamped by another build.
52    pub fn new(record: impl Into<String>) -> Self {
53        Self {
54            record: record.into(),
55            other: None,
56            any_stamp: false,
57        }
58    }
59}
60
61/// A record handed to the game: its file's name and its text.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct Record {
64    /// The file, as given.
65    pub file: String,
66    /// Its text.
67    pub text: String,
68}
69
70/// One run of a record.
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
72#[serde(deny_unknown_fields)]
73pub struct ReplayRun {
74    /// The record's file.
75    pub record: String,
76    /// Step 0 (the state it starts in), then one step per command.
77    pub steps: Vec<ReplayStep>,
78    /// How the game ended, in the game's words, if it did.
79    pub outcome: Option<String>,
80    /// What made the record, if it says: the game's `replay` passes on
81    /// its record's stamp ([`turns`] leaves it `None`).
82    #[serde(default)]
83    pub made: Option<Made>,
84}
85
86/// One step of a run: the state after a command.
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
88#[serde(deny_unknown_fields)]
89pub struct ReplayStep {
90    /// The step: 0 before any command, then the command's place from 1.
91    pub step: u32,
92    /// The command, in the game's words; none at step 0.
93    pub command: Option<String>,
94    /// The state's hash after it (sixteen hex digits).
95    pub hash: String,
96    /// What it did, in the game's words.
97    pub events: Vec<String>,
98    /// Why the rules refused it, if they did (the state didn't change).
99    pub refused: Option<String>,
100}
101
102/// Where two runs part.
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
104#[serde(deny_unknown_fields)]
105pub struct Difference {
106    /// The first step whose hashes differ, or that only one run has.
107    pub step: u32,
108    /// Each run's hash at that step; none for a run that has no such
109    /// step.
110    pub hashes: Vec<Option<String>>,
111}
112
113/// What `replay` found.
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
115#[serde(deny_unknown_fields)]
116pub struct ReplayOutput {
117    /// The two runs.
118    pub runs: Vec<ReplayRun>,
119    /// The first step where they differ, if they do.
120    pub first_difference: Option<Difference>,
121}
122
123/// Re-runs `commands` from `initial` by the rules, hashing the state
124/// before them and after each: the run of a turn game's record `file`. A
125/// refused command changes nothing and says why.
126pub fn turns<R>(file: &str, initial: R, commands: &[R::Command]) -> Result<ReplayRun, String>
127where
128    R: TurnRules + Serialize,
129    R::Command: Debug,
130    R::Event: Debug,
131    R::Error: Display,
132    R::Outcome: Debug,
133{
134    let mut state = initial;
135    let hash = |state: &R| {
136        state_hash(state)
137            .map(|h| h.to_string())
138            .map_err(|e| e.to_string())
139    };
140    let mut steps = vec![ReplayStep {
141        step: 0,
142        command: None,
143        hash: hash(&state)?,
144        events: Vec::new(),
145        refused: None,
146    }];
147    for (step, command) in (1..).zip(commands) {
148        let (events, refused) = match state.apply(command) {
149            Ok(events) => (events.iter().map(|e| format!("{e:?}")).collect(), None),
150            Err(e) => (Vec::new(), Some(e.to_string())),
151        };
152        steps.push(ReplayStep {
153            step,
154            command: Some(format!("{command:?}")),
155            hash: hash(&state)?,
156            events,
157            refused,
158        });
159    }
160    Ok(ReplayRun {
161        record: file.to_owned(),
162        steps,
163        outcome: state.outcome().map(|o| format!("{o:?}")),
164        made: None,
165    })
166}
167
168/// The first step where `a` and `b` differ, if they do.
169pub fn first_difference(a: &ReplayRun, b: &ReplayRun) -> Option<Difference> {
170    let longest = a.steps.len().max(b.steps.len());
171    (0..longest).find_map(|i| {
172        let hashes = [a, b].map(|run| run.steps.get(i).map(|s| s.hash.clone()));
173        (hashes[0] != hashes[1]).then(|| Difference {
174            step: u32::try_from(i).unwrap_or(u32::MAX),
175            hashes: hashes.to_vec(),
176        })
177    })
178}
179
180/// The record at `path` under `root`.
181fn read(root: &Path, path: &str) -> Result<Record, String> {
182    let text = fs::read_to_string(root.join(path)).map_err(|e| format!("{path}: {e}"))?;
183    Ok(Record {
184        file: path.to_owned(),
185        text,
186    })
187}
188
189/// `replay`: `input`'s records, under `root`, re-run by `game`.
190pub fn replay(game: &dyn GameTools, root: &Path, input: &ReplayInput) -> Answer<ReplayOutput> {
191    let runs = (|| {
192        let read = |path| read(root, path).map_err(|e| (codes::FILE, e));
193        let first = read(&input.record)?;
194        let second = match &input.other {
195            Some(other) => read(other)?,
196            None => first.clone(),
197        };
198        let replay = |record| game.replay(record).map_err(|e| (code_of(&e), e));
199        Ok::<_, (Code, String)>([replay(&first)?, replay(&second)?])
200    })();
201    let runs = match runs {
202        Ok(runs) => runs,
203        Err((code, e)) => return Answer::failed(Op::Replay, code, e),
204    };
205    // A record run twice is checked once.
206    let records = if input.other.is_some() {
207        &runs[..]
208    } else {
209        &runs[..1]
210    };
211    let mut limits = match stamps(game.made().as_ref(), records, input.any_stamp) {
212        Ok(limits) => limits,
213        Err(e) => return Answer::failed(Op::Replay, codes::OTHER_STAMP, e),
214    };
215    let difference = first_difference(&runs[0], &runs[1]);
216    if input.other.is_none() {
217        limits.push(
218            "the record ran twice in one process: what differs from one machine or build to \
219             another needs a second record made there"
220                .to_owned(),
221        );
222    }
223    let refused = runs[0].steps.iter().filter(|s| s.refused.is_some()).count();
224    if refused > 0 {
225        limits.push(format!(
226            "the rules refused {refused} command(s) of {}: the record doesn't play as it was made",
227            runs[0].record
228        ));
229    }
230    let output = ReplayOutput {
231        runs: runs.to_vec(),
232        first_difference: difference,
233    };
234    Answer::new(
235        Op::Replay,
236        output.first_difference.is_none(),
237        output,
238        limits,
239    )
240}
241
242/// The limits the stamps of `runs` put on what they show, checked against
243/// this build's, `now`; the refusal of the first stamped by another build,
244/// unless `any_stamp`.
245fn stamps(now: Option<&Made>, runs: &[ReplayRun], any_stamp: bool) -> Result<Vec<String>, String> {
246    let Some(now) = now else {
247        return Ok(vec![
248            "the game's tools give no stamp of this build (GameTools::made), so no record was \
249             checked against it: a difference may come from another build or other content"
250                .to_owned(),
251        ]);
252    };
253    let mut limits = Vec::new();
254    for run in runs {
255        let record = &run.record;
256        match run.made.as_ref().map(|made| made.check(now)) {
257            None => limits.push(format!(
258                "{record} has no stamp: a difference may come from another build or other content"
259            )),
260            Some(Ok(())) => {}
261            Some(Err(mismatch)) if any_stamp => limits.push(format!(
262                "{record} was {mismatch}: a difference may come from that, not a desync"
263            )),
264            Some(Err(mismatch)) => {
265                return Err(format!(
266                    "{record} was {mismatch}: make it again with this build, or replay it with \
267                     --any-stamp"
268                ));
269            }
270        }
271    }
272    Ok(limits)
273}
274
275/// What `replay` found, for people.
276pub fn text(output: &ReplayOutput) -> String {
277    let mut out = String::new();
278    let Some(first) = output.runs.first() else {
279        return out;
280    };
281    let _ = writeln!(
282        out,
283        "replay: {} ({} step(s)), outcome {}",
284        first.record,
285        first.steps.len().saturating_sub(1),
286        first.outcome.as_deref().unwrap_or("none")
287    );
288    for (i, run) in output.runs.iter().enumerate() {
289        if i > 0 && run.record == first.record {
290            continue;
291        }
292        match &run.made {
293            Some(made) => {
294                let _ = writeln!(out, "  {} was made with {made}", run.record);
295            }
296            None => {
297                let _ = writeln!(out, "  {} has no stamp", run.record);
298            }
299        }
300    }
301    for step in &first.steps {
302        let command = step.command.as_deref().unwrap_or("start");
303        let _ = write!(out, "  {:>4} {} {command}", step.step, step.hash);
304        if let Some(refused) = &step.refused {
305            let _ = write!(out, " (refused: {refused})");
306        } else if !step.events.is_empty() {
307            let _ = write!(out, " → {}", step.events.join(", "));
308        }
309        out.push('\n');
310    }
311    match &output.first_difference {
312        None => out.push_str("the two runs agree at every step\n"),
313        Some(d) => {
314            let shown: Vec<&str> = d
315                .hashes
316                .iter()
317                .map(|h| h.as_deref().unwrap_or("(none)"))
318                .collect();
319            let _ = writeln!(
320                out,
321                "the runs differ first at step {}: {}",
322                d.step,
323                shown.join(" vs ")
324            );
325        }
326    }
327    out
328}
329
330#[cfg(test)]
331mod tests {
332    use std::path::PathBuf;
333
334    use serde::Serialize;
335    use shuyi_core::TurnRules;
336
337    use super::*;
338    use crate::contract::fake::{Fake, stamp};
339    use shuyi_core::ContentHash;
340
341    /// A counter that ends at 3; an `Add` of more than 1 is refused.
342    #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
343    struct Counter(u32);
344
345    impl TurnRules for Counter {
346        type Command = u32;
347        type Event = u32;
348        type Error = String;
349        type Outcome = &'static str;
350
351        fn check(&self, add: &u32) -> Result<(), String> {
352            if *add > 1 {
353                Err(format!("{add} is too many"))
354            } else {
355                Ok(())
356            }
357        }
358
359        fn apply(&mut self, add: &u32) -> Result<Vec<u32>, String> {
360            self.check(add)?;
361            self.0 += add;
362            Ok(vec![self.0])
363        }
364
365        fn legal_commands(&self) -> Vec<u32> {
366            vec![0, 1]
367        }
368
369        fn outcome(&self) -> Option<&'static str> {
370            (self.0 >= 3).then_some("Done")
371        }
372    }
373
374    fn scratch(name: &str) -> PathBuf {
375        let dir = std::env::temp_dir().join(format!("shuyi-replay-{name}-{}", std::process::id()));
376        let _ = fs::remove_dir_all(&dir);
377        fs::create_dir_all(&dir).unwrap();
378        dir
379    }
380
381    #[test]
382    fn turns_hashes_the_state_before_and_after_each_command() {
383        let run = turns("r.ron", Counter(0), &[1, 5, 1, 1]).unwrap();
384        assert_eq!(run.record, "r.ron");
385        assert_eq!(run.steps.len(), 5);
386        let hash = |n: u32| state_hash(&Counter(n)).unwrap().to_string();
387        assert_eq!(run.steps[0].hash, hash(0));
388        assert_eq!(
389            (run.steps[0].step, run.steps[0].command.as_deref()),
390            (0, None)
391        );
392        assert_eq!(run.steps[1].command.as_deref(), Some("1"));
393        assert_eq!(run.steps[1].events, ["1"]);
394        assert_eq!(run.steps[1].hash, hash(1));
395        // Refused: the state, and its hash, stay.
396        assert_eq!(run.steps[2].refused.as_deref(), Some("5 is too many"));
397        let got = &run.steps[2].events;
398        assert!(got.is_empty(), "{got:?}");
399        assert_eq!(run.steps[2].hash, hash(1));
400        assert_eq!(run.steps[4].step, 4);
401        assert_eq!(run.steps[4].hash, hash(3));
402        assert_eq!(run.outcome.as_deref(), Some("\"Done\""));
403        assert_eq!(turns("r", Counter(0), &[]).unwrap().outcome, None);
404    }
405
406    fn run_of(hashes: &[&str]) -> ReplayRun {
407        ReplayRun {
408            record: "r".into(),
409            steps: (0..)
410                .zip(hashes)
411                .map(|(step, hash)| ReplayStep {
412                    step,
413                    command: None,
414                    hash: (*hash).to_owned(),
415                    events: Vec::new(),
416                    refused: None,
417                })
418                .collect(),
419            outcome: None,
420            made: None,
421        }
422    }
423
424    #[test]
425    fn the_first_difference_is_the_first_step_that_differs_or_only_one_has() {
426        let a = run_of(&["a", "b", "c"]);
427        assert_eq!(first_difference(&a, &a), None);
428        let b = run_of(&["a", "x", "c"]);
429        assert_eq!(
430            first_difference(&a, &b),
431            Some(Difference {
432                step: 1,
433                hashes: vec![Some("b".into()), Some("x".into())]
434            })
435        );
436        let short = run_of(&["a", "b"]);
437        assert_eq!(
438            first_difference(&a, &short),
439            Some(Difference {
440                step: 2,
441                hashes: vec![Some("c".into()), None]
442            })
443        );
444        assert_eq!(first_difference(&short, &a).unwrap().hashes[0], None);
445    }
446
447    #[test]
448    fn replay_runs_one_record_twice_or_two_side_by_side() {
449        let dir = scratch("runs");
450        fs::write(dir.join("a.ron"), "h0\nh1\nh2").unwrap();
451        fs::write(dir.join("b.ron"), "h0\nhx").unwrap();
452        fs::write(dir.join("c.ron"), "h0\nrefused").unwrap();
453        let input = |record: &str, other: Option<&str>| {
454            let mut input = ReplayInput::new(record);
455            input.other = other.map(Into::into);
456            input
457        };
458        let answer = replay(&Fake::default(), &dir, &input("a.ron", None));
459        assert!(answer.ok, "{answer:?}");
460        assert_eq!(answer.op, Op::Replay);
461        assert_eq!(answer.limits.len(), 2);
462        assert!(answer.limits[0].starts_with("the game's tools give no stamp of this build"));
463        assert!(answer.limits[1].starts_with("the record ran twice in one process"));
464        let out = answer.result.unwrap();
465        assert_eq!(out.runs.len(), 2);
466        assert_eq!(out.runs[0], out.runs[1]);
467        let answer = replay(&Fake::default(), &dir, &input("a.ron", Some("b.ron")));
468        assert!(!answer.ok);
469        assert_eq!(answer.limits.len(), 1, "{:?}", answer.limits);
470        let out = answer.result.unwrap();
471        assert_eq!(out.runs[1].record, "b.ron");
472        assert_eq!(out.first_difference.unwrap().step, 1);
473        let refused = replay(&Fake::default(), &dir, &input("c.ron", Some("c.ron")));
474        assert_eq!(
475            refused.limits[1..],
476            ["the rules refused 1 command(s) of c.ron: the record doesn't play as it was made"]
477        );
478        // What can't be read or run says why.
479        let missing = replay(&Fake::default(), &dir, &input("a.ron", Some("no.ron")));
480        assert_eq!(missing.code.as_deref(), Some("SH3004"));
481        assert_eq!(missing.exit_code(), 1);
482        assert!(missing.error.unwrap().starts_with("no.ron: "));
483        let game = Fake {
484            replay: Err("bad record".into()),
485            ..Fake::default()
486        };
487        let bad = replay(&game, &dir, &input("a.ron", None));
488        assert_eq!(bad.code.as_deref(), Some("SH3002"));
489        assert_eq!(bad.error.as_deref(), Some("bad record"));
490        let unsupported = Fake {
491            replay: Err(crate::game::unsupported("replay", "replay")),
492            ..Fake::default()
493        };
494        let answer = replay(&unsupported, &dir, &input("a.ron", None));
495        assert_eq!(answer.code.as_deref(), Some("SH3001"));
496        fs::remove_dir_all(&dir).unwrap();
497    }
498
499    #[test]
500    fn a_record_stamped_by_another_build_is_refused_unless_any_stamp() {
501        let dir = scratch("stamps");
502        let now = stamp("0.2.0", "demo", ContentHash(2));
503        let game = Fake {
504            made: Some(now.clone()),
505            ..Fake::default()
506        };
507        fs::write(
508            dir.join("same.ron"),
509            "made 0.2.0 demo 0000000000000002\nh0\nh1",
510        )
511        .unwrap();
512        fs::write(
513            dir.join("other.ron"),
514            "made 0.2.0 demo 0000000000000001\nh0\nh1",
515        )
516        .unwrap();
517        fs::write(dir.join("none.ron"), "h0\nh1").unwrap();
518        // Stamped by this build: it runs, with its stamp, and nothing to say.
519        let same = replay(&game, &dir, &ReplayInput::new("same.ron"));
520        assert!(same.ok, "{same:?}");
521        assert_eq!(same.limits.len(), 1, "{:?}", same.limits);
522        let out = same.result.unwrap();
523        assert_eq!(out.runs[0].made.as_ref(), Some(&now));
524        assert_eq!(out.runs[0].steps.len(), 2);
525        // Other content: refused before anything is compared, naming the
526        // record and both stamps' contents.
527        let mut input = ReplayInput::new("same.ron");
528        input.other = Some("other.ron".into());
529        let other = replay(&game, &dir, &input);
530        assert!(!other.ok);
531        assert_eq!(other.code.as_deref(), Some("SH3007"));
532        assert_eq!(other.exit_code(), 1);
533        assert_eq!(
534            other.error.as_deref(),
535            Some(
536                "other.ron was made with content 0000000000000001; this build: content \
537                 0000000000000002: make it again with this build, or replay it with --any-stamp"
538            )
539        );
540        assert!(other.result.is_none());
541        // With `any_stamp`, it runs and says why a difference may show.
542        input.any_stamp = true;
543        let anyway = replay(&game, &dir, &input);
544        assert!(anyway.ok, "{anyway:?}");
545        assert_eq!(
546            anyway.limits,
547            [
548                "other.ron was made with content 0000000000000001; this build: content \
549              0000000000000002: a difference may come from that, not a desync"
550            ]
551        );
552        // A record with no stamp runs, and says so.
553        let none = replay(&game, &dir, &ReplayInput::new("none.ron"));
554        assert!(none.ok, "{none:?}");
555        assert_eq!(
556            none.limits[0],
557            "none.ron has no stamp: a difference may come from another build or other content"
558        );
559        // A game that gives no stamp checks none.
560        let unstamped = replay(&Fake::default(), &dir, &ReplayInput::new("other.ron"));
561        assert!(unstamped.ok, "{unstamped:?}");
562        let got = &unstamped.limits[0];
563        assert!(got.contains("GameTools::made"), "{got}");
564        // The stamp goes out as text, and reads back.
565        let json = serde_json::to_value(&out.runs[0]).unwrap();
566        assert_eq!(
567            json["made"],
568            serde_json::json!({"engine": "0.2.0", "game": "demo", "content": "0000000000000002"})
569        );
570        let back: ReplayRun = serde_json::from_value(json).unwrap();
571        assert_eq!(back, out.runs[0]);
572        fs::remove_dir_all(&dir).unwrap();
573    }
574
575    #[test]
576    fn what_replay_found_reads_for_people() {
577        let mut run = run_of(&["h0", "h1", "h2"]);
578        run.outcome = Some("Won".into());
579        run.steps[1].command = Some("Move".into());
580        run.steps[1].events = vec!["Moved".into(), "Reached".into()];
581        run.steps[2].command = Some("Stay".into());
582        run.steps[2].refused = Some("over".into());
583        let agreed = ReplayOutput {
584            runs: vec![run.clone(), run.clone()],
585            first_difference: None,
586        };
587        assert_eq!(
588            text(&agreed),
589            "replay: r (2 step(s)), outcome Won\n  r has no stamp\n     0 h0 start\n     1 h1 \
590             Move → Moved, Reached\n     2 h2 Stay (refused: over)\nthe two runs agree at every \
591             step\n"
592        );
593        let mut stamped = run_of(&["h0"]);
594        stamped.made = Some(stamp("0.1.0", "demo", ContentHash(1)));
595        let mut other = run_of(&["h0"]);
596        other.record = "o".into();
597        let two = ReplayOutput {
598            runs: vec![stamped, other],
599            first_difference: None,
600        };
601        assert_eq!(
602            text(&two),
603            "replay: r (0 step(s)), outcome none\n  r was made with engine 0.1.0, game \"demo\", \
604             content 0000000000000001\n  o has no stamp\n     0 h0 start\nthe two runs agree at \
605             every step\n"
606        );
607        let parted = ReplayOutput {
608            runs: vec![run_of(&["a"])],
609            first_difference: Some(Difference {
610                step: 1,
611                hashes: vec![Some("b".into()), None],
612            }),
613        };
614        assert_eq!(
615            text(&parted),
616            "replay: r (0 step(s)), outcome none\n  r has no stamp\n     0 a start\nthe runs \
617             differ first at step 1: b vs (none)\n"
618        );
619        let none = ReplayOutput {
620            runs: Vec::new(),
621            first_difference: None,
622        };
623        assert_eq!(text(&none), "");
624    }
625
626    #[test]
627    fn a_new_input_runs_its_record_twice() {
628        let literal = ReplayInput {
629            record: "a.ron".to_owned(),
630            other: None,
631            any_stamp: false,
632        };
633        assert_eq!(ReplayInput::new("a.ron"), literal);
634    }
635}