From 5413c3669e3639344f7358e7ffb999fb481cb5fd Mon Sep 17 00:00:00 2001 From: Chidubem Mac-Anthony Date: Sat, 25 Jul 2026 11:59:59 +0100 Subject: [PATCH] feat: implement replay recording and playback engine - Add replay module at src/replay/mod.rs with ReplayEvent enum (HintReveal, AnswerAttempt, SessionStart, SessionEnd) - Events recorded with microsecond timestamps relative to session start - Completed replays serialized to .qreplay binary file via bincode - --playback CLI flag loads and replays .qreplay files - --speed 1|2|4 flag controls playback speed (default 1x) - Replay files include puzzle content hash for integrity cross-check - Replay summary (total time, hints, attempts, score) printed at end - 10 unit tests covering recording, serialization, playback, integrity Closes #47 --- Cargo.lock | 10 ++ Cargo.toml | 1 + src/lib.rs | 11 ++- src/main.rs | 58 +++++++++--- src/replay/mod.rs | 229 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 293 insertions(+), 16 deletions(-) create mode 100644 src/replay/mod.rs diff --git a/Cargo.lock b/Cargo.lock index a76385f..9760ed8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -48,6 +48,15 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "2.13.0" @@ -969,6 +978,7 @@ dependencies = [ name = "quest-contract" version = "0.1.0" dependencies = [ + "bincode", "chrono", "fluent", "fluent-bundle", diff --git a/Cargo.toml b/Cargo.toml index d0886c9..fe1ed67 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ tokio = { version = "1", features = ["full"] } thiserror = "1" chrono = { version = "0.4", features = ["serde"] } sha2 = "0.10" +bincode = "1.3" fluent = "0.16" fluent-bundle = "0.15" unic-langid = { version = "0.9", features = ["macros"] } diff --git a/src/lib.rs b/src/lib.rs index 816c33f..0102960 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,19 +1,20 @@ pub mod cli; -pub mod engine; -pub mod logic; -pub mod l10n; pub mod config; pub mod difficulty; +pub mod engine; pub mod errors; pub mod events; pub mod hints; pub mod input; pub mod inventory; +pub mod l10n; pub mod leaderboard; pub mod loader; -pub mod player; -pub mod puzzle; +pub mod logic; pub mod nft; +pub mod player; pub mod plugin; +pub mod puzzle; +pub mod replay; pub mod score; pub mod timer; diff --git a/src/main.rs b/src/main.rs index a4b0611..021f78d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,15 +8,17 @@ const DEFAULT_PUZZLES_DIR: &str = "puzzles"; fn main() { use smart_contract_game::player::Player; + use smart_contract_game::replay::ReplaySession; let args: Vec = std::env::args().collect(); - - let lang = args.iter() + + let lang = args + .iter() .position(|a| a == "--lang") .and_then(|i| args.get(i + 1)) .cloned() .unwrap_or_else(|| "en".to_string()); - + let l10n = L10n::new(&lang); if args.iter().any(|a| a == "--verify-puzzles") { @@ -33,8 +35,28 @@ fn main() { )); } + if let Some(pos) = args.iter().position(|a| a == "--playback") { + let file = args.get(pos + 1).cloned().unwrap_or_default(); + let speed = args + .iter() + .position(|a| a == "--speed") + .and_then(|i| args.get(i + 1)) + .and_then(|s| s.parse::().ok()) + .unwrap_or(1); + match ReplaySession::load(&file) { + Ok(replay) => { + replay.playback(speed); + std::process::exit(0); + } + Err(e) => { + eprintln!("Failed to load replay '{}': {e}", file); + std::process::exit(1); + } + } + } + let mut engine = engine::Engine::new(Duration::from_millis(16)); - + // Register the built-in core logic plugin with the engine use smart_contract_game::plugin::core_logic_plugin::CoreLogicPlugin; if let Err(e) = engine.register_plugin(Box::new(CoreLogicPlugin)) { @@ -58,11 +80,17 @@ fn main() { match player.to_json() { Ok(json) => println!("Player state: {json}"), - Err(e) => eprintln!("{}", l10n.get("error-serialize-player", Some(&{ - let mut args = fluent::FluentArgs::new(); - args.set("error", e.to_string()); - args - }))), + Err(e) => eprintln!( + "{}", + l10n.get( + "error-serialize-player", + Some(&{ + let mut args = fluent::FluentArgs::new(); + args.set("error", e.to_string()); + args + }) + ) + ), } } @@ -93,10 +121,18 @@ fn run_verify_puzzles(dir: &str, l10n: &L10n) -> i32 { let mut failures = 0; for report in &reports { match &report.result { - Ok(()) => println!("{} {}", l10n.get("ok-status", None), report.path.display()), + Ok(()) => println!( + "{} {}", + l10n.get("ok-status", None), + report.path.display() + ), Err(e) => { failures += 1; - println!("{} {}: {e}", l10n.get("fail-status", None), report.path.display()); + println!( + "{} {}: {e}", + l10n.get("fail-status", None), + report.path.display() + ); } } } diff --git a/src/replay/mod.rs b/src/replay/mod.rs new file mode 100644 index 0000000..9b12cb4 --- /dev/null +++ b/src/replay/mod.rs @@ -0,0 +1,229 @@ +use crate::config::DEFAULT_SAVE_PATH; +use crate::errors::AppError; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::Path; +use std::thread::sleep; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum ReplayEvent { + SessionStart, + SessionEnd, + HintReveal, + AnswerAttempt { answer: String }, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ReplaySession { + pub puzzle_content_hash: String, + pub events: Vec<(u64, ReplayEvent)>, + pub total_time_secs: u64, + pub hints_used: u32, + pub attempt_count: u32, + pub final_score: u64, +} + +impl ReplaySession { + pub fn new(puzzle_content_hash: impl Into) -> Self { + ReplaySession { + puzzle_content_hash: puzzle_content_hash.into(), + events: Vec::new(), + total_time_secs: 0, + hints_used: 0, + attempt_count: 0, + final_score: 0, + } + } + + pub fn record(&mut self, event: ReplayEvent) { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_micros() as u64; + self.events.push((now, event)); + } + + pub fn save(&self) -> Result { + let dir = Path::new(DEFAULT_SAVE_PATH); + if !dir.exists() { + fs::create_dir_all(dir).map_err(AppError::Io)?; + } + let filename = format!( + "{}.qreplay", + &self.puzzle_content_hash[..16.min(self.puzzle_content_hash.len())] + ); + let full_path = dir.join(&filename); + let bytes = bincode::serialize(self).map_err(|e| AppError::Io(std::io::Error::other(e)))?; + fs::write(&full_path, bytes).map_err(AppError::Io)?; + Ok(full_path.to_string_lossy().to_string()) + } + + pub fn load(path: impl AsRef) -> Result { + let bytes = fs::read(path.as_ref()).map_err(AppError::Io)?; + bincode::deserialize(&bytes) + .map_err(|e| AppError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e))) + } + + pub fn verify_integrity(&self, puzzle_content_hash: &str) -> bool { + self.puzzle_content_hash == puzzle_content_hash + } + + pub fn playback(&self, speed: u32) { + if self.events.is_empty() { + println!("Replay contains no events."); + return; + } + + let speed = speed.max(1); + let mut last_ts = self.events[0].0; + + println!( + "Replaying {} events at {}x speed...", + self.events.len(), + speed + ); + println!("Puzzle content hash: {}", self.puzzle_content_hash); + println!(); + + for (ts, event) in &self.events { + let delta = ts.saturating_sub(last_ts) / speed as u64; + if delta > 0 { + sleep(Duration::from_micros(delta)); + } + last_ts = *ts; + + match event { + ReplayEvent::SessionStart => println!("[Session Start]"), + ReplayEvent::SessionEnd => println!("[Session End]"), + ReplayEvent::HintReveal => println!("[Hint Reveal]"), + ReplayEvent::AnswerAttempt { answer } => { + println!("[Answer Attempt] \"{answer}\"") + } + } + } + + println!(); + println!("=== Replay Summary ==="); + println!("Total time: {}s", self.total_time_secs); + println!("Hints used: {}", self.hints_used); + println!("Attempts: {}", self.attempt_count); + println!("Final score: {}", self.final_score); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + #[test] + fn records_events_in_order() { + let mut replay = ReplaySession::new("abc123"); + replay.record(ReplayEvent::SessionStart); + replay.record(ReplayEvent::HintReveal); + replay.record(ReplayEvent::AnswerAttempt { + answer: "test".into(), + }); + replay.record(ReplayEvent::SessionEnd); + + assert_eq!(replay.events.len(), 4); + assert!(matches!(replay.events[0].1, ReplayEvent::SessionStart)); + assert!(matches!(replay.events[1].1, ReplayEvent::HintReveal)); + assert!(matches!( + replay.events[2].1, + ReplayEvent::AnswerAttempt { .. } + )); + assert!(matches!(replay.events[3].1, ReplayEvent::SessionEnd)); + } + + #[test] + fn timestamps_are_monotonic() { + let mut replay = ReplaySession::new("abc123"); + replay.record(ReplayEvent::SessionStart); + std::thread::sleep(Duration::from_micros(10)); + replay.record(ReplayEvent::HintReveal); + + assert!(replay.events[1].0 > replay.events[0].0); + } + + #[test] + fn serialization_roundtrip() { + let mut replay = ReplaySession::new("hash123"); + replay.record(ReplayEvent::SessionStart); + replay.record(ReplayEvent::HintReveal); + replay.total_time_secs = 42; + replay.hints_used = 1; + replay.attempt_count = 3; + replay.final_score = 100; + + let bytes = bincode::serialize(&replay).unwrap(); + let restored: ReplaySession = bincode::deserialize(&bytes).unwrap(); + + assert_eq!(replay, restored); + } + + #[test] + fn save_and_load_qreplay() { + let mut replay = ReplaySession::new("test-hash-123"); + replay.record(ReplayEvent::SessionStart); + replay.record(ReplayEvent::AnswerAttempt { + answer: "foo".into(), + }); + replay.record(ReplayEvent::SessionEnd); + replay.total_time_secs = 10; + replay.hints_used = 2; + replay.attempt_count = 1; + replay.final_score = 50; + + let path = replay.save().expect("save"); + assert!(path.ends_with(".qreplay")); + + let loaded = ReplaySession::load(&path).expect("load"); + assert_eq!(replay, loaded); + + let _ = std::fs::remove_file(&path); + } + + #[test] + fn integrity_check_passes_for_matching_hash() { + let replay = ReplaySession::new("correct-hash"); + assert!(replay.verify_integrity("correct-hash")); + } + + #[test] + fn integrity_check_fails_for_mismatched_hash() { + let replay = ReplaySession::new("original-hash"); + assert!(!replay.verify_integrity("tampered-hash")); + } + + #[test] + fn playback_empty_replay_does_not_panic() { + let replay = ReplaySession::new("hash"); + replay.playback(1); + } + + #[test] + fn new_replay_has_no_events() { + let replay = ReplaySession::new("hash"); + assert!(replay.events.is_empty()); + assert_eq!(replay.hints_used, 0); + assert_eq!(replay.attempt_count, 0); + assert_eq!(replay.final_score, 0); + } + + #[test] + fn load_nonexistent_file_returns_error() { + let result = ReplaySession::load("/nonexistent/path.qreplay"); + assert!(result.is_err()); + } + + #[test] + fn load_corrupted_file_returns_error() { + let mut f = NamedTempFile::new().expect("temp file"); + f.write_all(b"not valid bincode data").expect("write"); + let result = ReplaySession::load(f.path()); + assert!(result.is_err()); + } +}