1use 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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
31#[serde(deny_unknown_fields)]
32#[non_exhaustive]
35pub struct ReplayInput {
36 pub record: String,
38 #[serde(default)]
41 pub other: Option<String>,
42 #[serde(default)]
46 pub any_stamp: bool,
47}
48
49impl ReplayInput {
50 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#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct Record {
64 pub file: String,
66 pub text: String,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
72#[serde(deny_unknown_fields)]
73pub struct ReplayRun {
74 pub record: String,
76 pub steps: Vec<ReplayStep>,
78 pub outcome: Option<String>,
80 #[serde(default)]
83 pub made: Option<Made>,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
88#[serde(deny_unknown_fields)]
89pub struct ReplayStep {
90 pub step: u32,
92 pub command: Option<String>,
94 pub hash: String,
96 pub events: Vec<String>,
98 pub refused: Option<String>,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
104#[serde(deny_unknown_fields)]
105pub struct Difference {
106 pub step: u32,
108 pub hashes: Vec<Option<String>>,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
115#[serde(deny_unknown_fields)]
116pub struct ReplayOutput {
117 pub runs: Vec<ReplayRun>,
119 pub first_difference: Option<Difference>,
121}
122
123pub 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
168pub 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
180fn 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
189pub 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 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
242fn 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
275pub 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 #[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 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 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 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 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 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 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 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 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}