diff --git a/Cargo.lock b/Cargo.lock index 4ec4c78..e9dc39c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,202 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "flowstate-replay" +version = "0.0.0" +dependencies = [ + "flowstate-sim", + "flowstate-wire", + "prost", + "sha2", +] + +[[package]] +name = "flowstate-server" +version = "0.0.0" +dependencies = [ + "flowstate-replay", + "flowstate-sim", + "flowstate-wire", + "prost", +] + [[package]] name = "flowstate-sim" version = "0.0.0" + +[[package]] +name = "flowstate-wire" +version = "0.0.0" +dependencies = [ + "flowstate-sim", + "prost", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "libc" +version = "0.2.178" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "syn" +version = "2.0.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" diff --git a/crates/replay/Cargo.toml b/crates/replay/Cargo.toml new file mode 100644 index 0000000..2844a49 --- /dev/null +++ b/crates/replay/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "flowstate-replay" +version = "0.0.0" +edition = "2024" +publish = false +description = "Replay artifact generation and verification for Flowstate" + +[dependencies] +flowstate-sim = { path = "../sim" } +flowstate-wire = { path = "../wire" } +prost = "0.13" +sha2 = "0.10" + +[dev-dependencies] + +[lints.rust] +unsafe_code = "deny" diff --git a/crates/replay/src/lib.rs b/crates/replay/src/lib.rs new file mode 100644 index 0000000..066823e --- /dev/null +++ b/crates/replay/src/lib.rs @@ -0,0 +1,925 @@ +//! Flowstate Replay System +//! +//! This crate provides replay artifact generation and verification. +//! +//! # Architecture +//! +//! The replay system consists of: +//! - `ReplayRecorder`: Collects AppliedInputs during a match +//! - `ReplayVerifier`: Verifies replay artifacts produce identical outcomes +//! - Build fingerprint acquisition for same-build verification scope +//! +//! # References +//! +//! - INV-0006: Replay Verifiability +//! - DM-0017: ReplayArtifact +//! - DM-0024: AppliedInput +//! - ADR-0007: StateDigest Algorithm + +#![deny(unsafe_code)] + +use std::collections::HashMap; +use std::fs; +use std::io::{self, Read, Write}; +use std::path::Path; + +use flowstate_sim::{ + self, Baseline, MOVE_SPEED, PlayerId, STATE_DIGEST_ALGO_ID, StepInput, Tick, World, +}; +use flowstate_wire::{ + AppliedInputProto, BuildFingerprint, EntitySnapshotProto, JoinBaseline, PlayerEntityMapping, + ReplayArtifact, TuningParameter, +}; +use prost::Message; +use sha2::{Digest, Sha256}; + +// ============================================================================ +// Applied Input +// ============================================================================ + +/// Applied input representing post-normalization input. +/// Ref: DM-0024 +/// +/// This is the Server Edge's canonical "input truth" for a player at a tick. +#[derive(Debug, Clone, PartialEq)] +pub struct AppliedInput { + pub tick: Tick, + pub player_id: PlayerId, + pub move_dir: [f64; 2], + pub is_fallback: bool, +} + +impl AppliedInput { + /// Convert to StepInput for simulation. + pub fn to_step_input(&self) -> StepInput { + StepInput { + player_id: self.player_id, + move_dir: self.move_dir, + } + } +} + +impl From for AppliedInputProto { + fn from(input: AppliedInput) -> Self { + Self { + tick: input.tick, + player_id: u32::from(input.player_id), + move_dir: input.move_dir.to_vec(), + is_fallback: input.is_fallback, + } + } +} + +impl TryFrom for AppliedInput { + type Error = &'static str; + + fn try_from(proto: AppliedInputProto) -> Result { + if proto.move_dir.len() != 2 { + return Err("move_dir must have exactly 2 elements"); + } + Ok(Self { + tick: proto.tick, + player_id: proto.player_id as PlayerId, + move_dir: [proto.move_dir[0], proto.move_dir[1]], + is_fallback: proto.is_fallback, + }) + } +} + +// ============================================================================ +// Replay Recorder +// ============================================================================ + +/// Configuration for replay recording. +#[derive(Debug, Clone)] +pub struct ReplayConfig { + pub seed: u64, + pub tick_rate_hz: u32, + pub rng_algorithm: String, + pub test_mode: bool, + pub test_player_ids: Vec, +} + +impl Default for ReplayConfig { + fn default() -> Self { + Self { + seed: 0, + tick_rate_hz: 60, + rng_algorithm: "none".to_string(), // v0 doesn't use RNG in movement + test_mode: false, + test_player_ids: Vec::new(), + } + } +} + +/// Records match data for replay artifact generation. +/// Ref: DM-0017 +pub struct ReplayRecorder { + config: ReplayConfig, + entity_spawn_order: Vec, + player_entity_mapping: Vec<(PlayerId, flowstate_sim::EntityId)>, + initial_baseline: Option, + inputs: Vec, + build_fingerprint: Option, +} + +/// Build fingerprint data. +#[derive(Debug, Clone)] +pub struct BuildFingerprintData { + pub binary_sha256: String, + pub target_triple: String, + pub profile: String, + pub git_commit: String, +} + +impl ReplayRecorder { + /// Create a new replay recorder. + pub fn new(config: ReplayConfig) -> Self { + Self { + config, + entity_spawn_order: Vec::new(), + player_entity_mapping: Vec::new(), + initial_baseline: None, + inputs: Vec::new(), + build_fingerprint: None, + } + } + + /// Record entity spawn order. + pub fn record_spawn(&mut self, player_id: PlayerId, entity_id: flowstate_sim::EntityId) { + self.entity_spawn_order.push(player_id); + self.player_entity_mapping.push((player_id, entity_id)); + } + + /// Record the initial baseline. + pub fn record_baseline(&mut self, baseline: Baseline) { + self.initial_baseline = Some(baseline); + } + + /// Record an applied input. + pub fn record_input(&mut self, input: AppliedInput) { + self.inputs.push(input); + } + + /// Set the build fingerprint. + pub fn set_build_fingerprint(&mut self, fingerprint: BuildFingerprintData) { + self.build_fingerprint = Some(fingerprint); + } + + /// Finalize the replay artifact. + pub fn finalize( + self, + final_digest: u64, + checkpoint_tick: Tick, + end_reason: &str, + ) -> ReplayArtifact { + let initial_baseline = self.initial_baseline.map(|b| JoinBaseline { + tick: b.tick, + entities: b + .entities + .into_iter() + .map(|e| EntitySnapshotProto { + entity_id: e.entity_id, + position: e.position.to_vec(), + velocity: e.velocity.to_vec(), + }) + .collect(), + digest: b.digest, + }); + + let player_entity_mapping: Vec<_> = self + .player_entity_mapping + .iter() + .map(|(pid, eid)| PlayerEntityMapping { + player_id: u32::from(*pid), + entity_id: *eid, + }) + .collect(); + + let tuning_parameters = vec![TuningParameter { + key: "move_speed".to_string(), + value: MOVE_SPEED, + }]; + + let build_fingerprint = self.build_fingerprint.map(|f| BuildFingerprint { + binary_sha256: f.binary_sha256, + target_triple: f.target_triple, + profile: f.profile, + git_commit: f.git_commit, + }); + + ReplayArtifact { + replay_format_version: 1, + initial_baseline, + seed: self.config.seed, + rng_algorithm: self.config.rng_algorithm, + tick_rate_hz: self.config.tick_rate_hz, + state_digest_algo_id: STATE_DIGEST_ALGO_ID.to_string(), + entity_spawn_order: self + .entity_spawn_order + .iter() + .map(|&p| u32::from(p)) + .collect(), + player_entity_mapping, + tuning_parameters, + inputs: self.inputs.into_iter().map(Into::into).collect(), + build_fingerprint, + final_digest, + checkpoint_tick, + end_reason: end_reason.to_string(), + test_mode: self.config.test_mode, + test_player_ids: self + .config + .test_player_ids + .iter() + .map(|&p| u32::from(p)) + .collect(), + } + } +} + +// ============================================================================ +// Replay Verification +// ============================================================================ + +/// Replay verification error. +#[derive(Debug, Clone, PartialEq)] +pub enum VerifyError { + /// Build fingerprint mismatch. + BuildMismatch { expected: String, actual: String }, + /// Missing initial baseline. + MissingBaseline, + /// Initialization anchor (baseline digest) mismatch. + InitializationAnchorMismatch { expected: u64, actual: u64 }, + /// Spawn reconstruction mismatch. + SpawnReconstructionMismatch { + player_id: PlayerId, + expected_entity_id: flowstate_sim::EntityId, + actual_entity_id: flowstate_sim::EntityId, + }, + /// Input stream validation failed. + InputStreamInvalid { reason: String }, + /// Final digest mismatch. + FinalDigestMismatch { expected: u64, actual: u64 }, + /// Checkpoint tick mismatch. + CheckpointTickMismatch { expected: Tick, actual: Tick }, + /// Invalid replay artifact format. + InvalidFormat { reason: String }, +} + +impl std::fmt::Display for VerifyError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::BuildMismatch { expected, actual } => { + write!( + f, + "Build fingerprint mismatch: expected {expected}, got {actual}" + ) + } + Self::MissingBaseline => write!(f, "Missing initial baseline in replay artifact"), + Self::InitializationAnchorMismatch { expected, actual } => { + write!( + f, + "Initialization anchor mismatch: expected {expected:#x}, got {actual:#x}" + ) + } + Self::SpawnReconstructionMismatch { + player_id, + expected_entity_id, + actual_entity_id, + } => { + write!( + f, + "Spawn reconstruction mismatch for player {player_id}: expected entity {expected_entity_id}, got {actual_entity_id}" + ) + } + Self::InputStreamInvalid { reason } => { + write!(f, "Input stream invalid: {reason}") + } + Self::FinalDigestMismatch { expected, actual } => { + write!( + f, + "Final digest mismatch: expected {expected:#x}, got {actual:#x}" + ) + } + Self::CheckpointTickMismatch { expected, actual } => { + write!( + f, + "Checkpoint tick mismatch: expected {expected}, got {actual}" + ) + } + Self::InvalidFormat { reason } => { + write!(f, "Invalid replay format: {reason}") + } + } + } +} + +impl std::error::Error for VerifyError {} + +/// Options for replay verification. +#[derive(Debug, Clone)] +pub struct VerifyOptions { + /// Whether to strictly enforce build fingerprint matching. + /// - true: fail on mismatch (CI/Tier-0) + /// - false: warn but continue (dev mode) + pub strict_build_check: bool, + /// Current build fingerprint for comparison. + pub current_build: Option, +} + +impl Default for VerifyOptions { + fn default() -> Self { + Self { + strict_build_check: true, + current_build: None, + } + } +} + +/// Verify a replay artifact produces the recorded outcome. +/// Ref: INV-0006, T0.9 +/// +/// # Verification Steps (per spec): +/// 1. Verify build fingerprint matches (strict mode: fail; dev mode: warn) +/// 2. Validate AppliedInput stream integrity +/// 3. Initialize World with recorded seed and tick_rate_hz +/// 4. Reconstruct initialization (spawn order, verify entity IDs) +/// 5. Verify baseline digest (initialization anchor) +/// 6. Replay ticks [initial_baseline.tick, checkpoint_tick) +/// 7. Assert world.tick() == checkpoint_tick +/// 8. Assert world.state_digest() == final_digest +pub fn verify_replay( + artifact: &ReplayArtifact, + options: &VerifyOptions, +) -> Result<(), VerifyError> { + // Step 1: Verify build fingerprint + if let (Some(recorded), Some(current)) = (&artifact.build_fingerprint, &options.current_build) { + let mismatch = recorded.binary_sha256 != current.binary_sha256 + || recorded.target_triple != current.target_triple + || recorded.profile != current.profile; + if mismatch && options.strict_build_check { + return Err(VerifyError::BuildMismatch { + expected: recorded.binary_sha256.clone(), + actual: current.binary_sha256.clone(), + }); + } + // In non-strict mode, we'd log a warning here (not implemented for v0) + } + + // Step 2: Validate input stream integrity + validate_input_stream(artifact)?; + + // Get initial baseline + let baseline_proto = artifact + .initial_baseline + .as_ref() + .ok_or(VerifyError::MissingBaseline)?; + + let initial_tick = baseline_proto.tick; + let checkpoint_tick = artifact.checkpoint_tick; + + // Step 3: Initialize World + let mut world = World::new(artifact.seed, artifact.tick_rate_hz); + + // Step 4: Reconstruct initialization (spawn order) + let player_entity_map: HashMap = artifact + .player_entity_mapping + .iter() + .map(|m| (m.player_id, m.entity_id)) + .collect(); + + for &player_id_u32 in &artifact.entity_spawn_order { + let player_id = player_id_u32 as PlayerId; + let actual_entity_id = world.spawn_character(player_id); + + if let Some(&expected_entity_id) = player_entity_map.get(&player_id_u32) + && actual_entity_id != expected_entity_id + { + return Err(VerifyError::SpawnReconstructionMismatch { + player_id, + expected_entity_id, + actual_entity_id, + }); + } + } + + // Step 5: Verify initialization anchor (baseline digest) + let baseline = world.baseline(); + if baseline.digest != baseline_proto.digest { + return Err(VerifyError::InitializationAnchorMismatch { + expected: baseline_proto.digest, + actual: baseline.digest, + }); + } + + // Convert inputs to lookup map: tick -> Vec + let mut inputs_by_tick: HashMap> = HashMap::new(); + for input_proto in &artifact.inputs { + let input: AppliedInput = + input_proto + .clone() + .try_into() + .map_err(|e: &str| VerifyError::InvalidFormat { + reason: e.to_string(), + })?; + inputs_by_tick.entry(input.tick).or_default().push(input); + } + + // Step 6: Replay ticks [initial_tick, checkpoint_tick) + for tick in initial_tick..checkpoint_tick { + let mut step_inputs: Vec = inputs_by_tick + .get(&tick) + .map(|inputs| inputs.iter().map(AppliedInput::to_step_input).collect()) + .unwrap_or_default(); + + // Sort by player_id (INV-0007) - defense in depth, verifier canonicalizes + step_inputs.sort_by_key(|i| i.player_id); + + let _ = world.advance(tick, &step_inputs); + } + + // Step 7: Verify checkpoint tick + if world.tick() != checkpoint_tick { + return Err(VerifyError::CheckpointTickMismatch { + expected: checkpoint_tick, + actual: world.tick(), + }); + } + + // Step 8: Verify final digest + let actual_digest = world.state_digest(); + if actual_digest != artifact.final_digest { + return Err(VerifyError::FinalDigestMismatch { + expected: artifact.final_digest, + actual: actual_digest, + }); + } + + Ok(()) +} + +/// Validate the input stream integrity. +/// Ref: INV-0006 AppliedInput stream validation +fn validate_input_stream(artifact: &ReplayArtifact) -> Result<(), VerifyError> { + let baseline = artifact + .initial_baseline + .as_ref() + .ok_or(VerifyError::MissingBaseline)?; + + let initial_tick = baseline.tick; + let checkpoint_tick = artifact.checkpoint_tick; + + // Get player IDs from mapping + let player_ids: Vec = artifact + .player_entity_mapping + .iter() + .map(|m| m.player_id) + .collect(); + + // Build a set of (player_id, tick) pairs from inputs + let mut input_pairs: HashMap<(u32, Tick), usize> = HashMap::new(); + for input in &artifact.inputs { + let key = (input.player_id, input.tick); + *input_pairs.entry(key).or_insert(0) += 1; + } + + // Verify: for each player, for each tick in range, exactly one input + for &player_id in &player_ids { + for tick in initial_tick..checkpoint_tick { + let key = (player_id, tick); + match input_pairs.get(&key) { + None => { + return Err(VerifyError::InputStreamInvalid { + reason: format!("Missing input for player {player_id} at tick {tick}"), + }); + } + Some(&count) if count > 1 => { + return Err(VerifyError::InputStreamInvalid { + reason: format!("Duplicate input for player {player_id} at tick {tick}"), + }); + } + Some(_) => {} + } + } + } + + // Verify: no inputs outside the range + for input in &artifact.inputs { + if input.tick < initial_tick || input.tick >= checkpoint_tick { + return Err(VerifyError::InputStreamInvalid { + reason: format!( + "Input for player {} at tick {} is outside valid range [{}, {})", + input.player_id, input.tick, initial_tick, checkpoint_tick + ), + }); + } + if !player_ids.contains(&input.player_id) { + return Err(VerifyError::InputStreamInvalid { + reason: format!("Input references unknown player_id {}", input.player_id), + }); + } + } + + Ok(()) +} + +// ============================================================================ +// Build Fingerprint Acquisition +// ============================================================================ + +/// Acquire the current build fingerprint. +/// Ref: Spec "Build Fingerprint Acquisition" +/// +/// # Returns +/// - `Ok(fingerprint)` on success +/// - `Err(io::Error)` if executable cannot be read +/// +/// # Tier-0/CI Behavior +/// If this fails, Tier-0/CI MUST fail. Dev MAY proceed with "unknown". +pub fn acquire_build_fingerprint() -> io::Result { + // Get current executable path + let exe_path = std::env::current_exe()?; + + // Read executable bytes and compute SHA-256 + let mut file = fs::File::open(&exe_path)?; + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 8192]; + loop { + let n = file.read(&mut buffer)?; + if n == 0 { + break; + } + hasher.update(&buffer[..n]); + } + let binary_sha256 = format!("{:x}", hasher.finalize()); + + // Get target triple + let target_triple = get_target_triple(); + + // Get profile + let profile = if cfg!(debug_assertions) { + "dev" + } else { + "release" + }; + + // Get git commit (best effort) + let git_commit = get_git_commit().unwrap_or_else(|| "unknown".to_string()); + + Ok(BuildFingerprintData { + binary_sha256, + target_triple, + profile: profile.to_string(), + git_commit, + }) +} + +/// Get the target triple for the current build. +fn get_target_triple() -> String { + // Use compile-time constant + #[cfg(target_os = "windows")] + { + #[cfg(target_arch = "x86_64")] + return "x86_64-pc-windows-msvc".to_string(); + #[cfg(target_arch = "aarch64")] + return "aarch64-pc-windows-msvc".to_string(); + #[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))] + return "unknown-pc-windows-msvc".to_string(); + } + #[cfg(target_os = "linux")] + { + #[cfg(target_arch = "x86_64")] + return "x86_64-unknown-linux-gnu".to_string(); + #[cfg(target_arch = "aarch64")] + return "aarch64-unknown-linux-gnu".to_string(); + #[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))] + return "unknown-unknown-linux-gnu".to_string(); + } + #[cfg(target_os = "macos")] + { + #[cfg(target_arch = "x86_64")] + return "x86_64-apple-darwin".to_string(); + #[cfg(target_arch = "aarch64")] + return "aarch64-apple-darwin".to_string(); + #[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))] + return "unknown-apple-darwin".to_string(); + } + #[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))] + { + "unknown-unknown-unknown".to_string() + } +} + +/// Get the git commit hash (best effort). +fn get_git_commit() -> Option { + // Try to read from environment (set by build script or CI) + if let Ok(commit) = std::env::var("FLOWSTATE_GIT_COMMIT") { + return Some(commit); + } + + // Could shell out to git, but for v0 we just return None if not set + None +} + +// ============================================================================ +// Replay I/O +// ============================================================================ + +/// Write a replay artifact to a file. +pub fn write_replay(artifact: &ReplayArtifact, path: &Path) -> io::Result<()> { + // Ensure parent directory exists + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + + // Check for existing file (collision handling per spec) + if path.exists() { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + format!("Replay artifact already exists at {}", path.display()), + )); + } + + // Encode and write + let encoded = artifact.encode_to_vec(); + let mut file = fs::File::create(path)?; + file.write_all(&encoded)?; + + Ok(()) +} + +/// Read a replay artifact from a file. +pub fn read_replay(path: &Path) -> io::Result { + let data = fs::read(path)?; + ReplayArtifact::decode(data.as_slice()).map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("Failed to decode replay: {e}"), + ) + }) +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + fn create_test_artifact() -> ReplayArtifact { + let mut recorder = ReplayRecorder::new(ReplayConfig { + seed: 42, + tick_rate_hz: 60, + rng_algorithm: "none".to_string(), + test_mode: false, + test_player_ids: Vec::new(), + }); + + // Create a world and record spawns + let mut world = World::new(42, 60); + let entity1 = world.spawn_character(0); + let entity2 = world.spawn_character(1); + recorder.record_spawn(0, entity1); + recorder.record_spawn(1, entity2); + + // Record baseline + recorder.record_baseline(world.baseline()); + + // Record inputs for 10 ticks + for tick in 0..10 { + recorder.record_input(AppliedInput { + tick, + player_id: 0, + move_dir: [1.0, 0.0], + is_fallback: false, + }); + recorder.record_input(AppliedInput { + tick, + player_id: 1, + move_dir: [0.0, 1.0], + is_fallback: false, + }); + + // Advance world + let inputs = [ + StepInput { + player_id: 0, + move_dir: [1.0, 0.0], + }, + StepInput { + player_id: 1, + move_dir: [0.0, 1.0], + }, + ]; + world.advance(tick, &inputs); + } + + // Finalize + recorder.finalize(world.state_digest(), world.tick(), "complete") + } + + /// T0.8: Replay artifact generated with all required fields. + #[test] + fn test_t0_08_replay_artifact_has_required_fields() { + let artifact = create_test_artifact(); + + assert_eq!(artifact.replay_format_version, 1); + assert!(artifact.initial_baseline.is_some()); + assert_eq!(artifact.seed, 42); + assert!(!artifact.rng_algorithm.is_empty()); + assert_eq!(artifact.tick_rate_hz, 60); + assert_eq!( + artifact.state_digest_algo_id, + "statedigest-v0-fnv1a64-le-f64canon-eidasc-posvel" + ); + assert_eq!(artifact.entity_spawn_order.len(), 2); + assert_eq!(artifact.player_entity_mapping.len(), 2); + assert!(!artifact.tuning_parameters.is_empty()); + assert_eq!(artifact.inputs.len(), 20); // 10 ticks * 2 players + assert_eq!(artifact.checkpoint_tick, 10); + assert_eq!(artifact.end_reason, "complete"); + } + + /// T0.9: Replay verification passes. + #[test] + fn test_t0_09_replay_verification_passes() { + let artifact = create_test_artifact(); + let options = VerifyOptions { + strict_build_check: false, // Don't check build in unit tests + current_build: None, + }; + + let result = verify_replay(&artifact, &options); + assert!(result.is_ok(), "Replay verification failed: {result:?}"); + } + + /// T0.10: Initialization anchor failure. + #[test] + fn test_t0_10_initialization_anchor_failure() { + let mut artifact = create_test_artifact(); + + // Mutate the baseline digest + if let Some(ref mut baseline) = artifact.initial_baseline { + baseline.digest ^= 0xDEADBEEF; + } + + let options = VerifyOptions { + strict_build_check: false, + current_build: None, + }; + + let result = verify_replay(&artifact, &options); + assert!(matches!( + result, + Err(VerifyError::InitializationAnchorMismatch { .. }) + )); + } + + /// T0.12: LastKnownIntent determinism. + #[test] + fn test_t0_12_lki_determinism() { + let mut recorder = ReplayRecorder::new(ReplayConfig::default()); + + let mut world = World::new(0, 60); + let entity1 = world.spawn_character(0); + recorder.record_spawn(0, entity1); + recorder.record_baseline(world.baseline()); + + // Record inputs with some fallbacks + for tick in 0..10 { + let is_fallback = tick % 3 == 0; // Every 3rd tick is LKI + recorder.record_input(AppliedInput { + tick, + player_id: 0, + move_dir: if is_fallback { [0.0, 0.0] } else { [1.0, 0.0] }, + is_fallback, + }); + + let inputs = [StepInput { + player_id: 0, + move_dir: if is_fallback { [0.0, 0.0] } else { [1.0, 0.0] }, + }]; + world.advance(tick, &inputs); + } + + let artifact = recorder.finalize(world.state_digest(), world.tick(), "complete"); + + // Verify replay + let options = VerifyOptions { + strict_build_check: false, + current_build: None, + }; + let result = verify_replay(&artifact, &options); + assert!(result.is_ok(), "Replay with LKI inputs failed: {result:?}"); + } + + /// T0.12a: Non-canonical AppliedInput storage order. + #[test] + fn test_t0_12a_noncanonical_input_order() { + let mut recorder = ReplayRecorder::new(ReplayConfig::default()); + + let mut world = World::new(0, 60); + let entity1 = world.spawn_character(0); + let entity2 = world.spawn_character(1); + recorder.record_spawn(0, entity1); + recorder.record_spawn(1, entity2); + recorder.record_baseline(world.baseline()); + + // Intentionally record inputs in non-canonical order (player 1 before player 0) + for tick in 0..5 { + // Wrong order: player 1 first + recorder.record_input(AppliedInput { + tick, + player_id: 1, + move_dir: [0.0, 1.0], + is_fallback: false, + }); + recorder.record_input(AppliedInput { + tick, + player_id: 0, + move_dir: [1.0, 0.0], + is_fallback: false, + }); + + // Advance world with correct order + let inputs = [ + StepInput { + player_id: 0, + move_dir: [1.0, 0.0], + }, + StepInput { + player_id: 1, + move_dir: [0.0, 1.0], + }, + ]; + world.advance(tick, &inputs); + } + + let artifact = recorder.finalize(world.state_digest(), world.tick(), "complete"); + + // Verifier should canonicalize and succeed + let options = VerifyOptions { + strict_build_check: false, + current_build: None, + }; + let result = verify_replay(&artifact, &options); + assert!( + result.is_ok(), + "Verifier should handle non-canonical order: {result:?}" + ); + } + + #[test] + fn test_applied_input_conversion() { + let input = AppliedInput { + tick: 100, + player_id: 5, + move_dir: [0.5, -0.5], + is_fallback: true, + }; + + let proto: AppliedInputProto = input.clone().into(); + let back: AppliedInput = proto.try_into().unwrap(); + + assert_eq!(input, back); + } + + #[test] + fn test_input_stream_validation_missing() { + let mut artifact = create_test_artifact(); + + // Remove an input + artifact + .inputs + .retain(|i| !(i.tick == 5 && i.player_id == 0)); + + let options = VerifyOptions::default(); + let result = verify_replay(&artifact, &options); + assert!(matches!( + result, + Err(VerifyError::InputStreamInvalid { .. }) + )); + } + + #[test] + fn test_input_stream_validation_duplicate() { + let mut artifact = create_test_artifact(); + + // Add a duplicate + artifact.inputs.push(AppliedInputProto { + tick: 5, + player_id: 0, + move_dir: vec![1.0, 0.0], + is_fallback: false, + }); + + let options = VerifyOptions::default(); + let result = verify_replay(&artifact, &options); + assert!(matches!( + result, + Err(VerifyError::InputStreamInvalid { .. }) + )); + } +} diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml new file mode 100644 index 0000000..64c691f --- /dev/null +++ b/crates/server/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "flowstate-server" +version = "0.0.0" +edition = "2024" +publish = false +description = "Server Edge for Flowstate multiplayer" + +[dependencies] +flowstate-sim = { path = "../sim" } +flowstate-wire = { path = "../wire" } +flowstate-replay = { path = "../replay" } +prost = "0.13" + +[dev-dependencies] + +[lints.rust] +unsafe_code = "deny" diff --git a/crates/server/src/input_buffer.rs b/crates/server/src/input_buffer.rs new file mode 100644 index 0000000..27c2709 --- /dev/null +++ b/crates/server/src/input_buffer.rs @@ -0,0 +1,349 @@ +//! Input buffering for Server Edge. +//! +//! Ref: FS-0007 Validation Rules +//! - Buffer keyed by (player_id, tick) +//! - InputSeq selection: greatest wins +//! - Rate limiting: per-tick limit = ceil(input_rate_limit_per_sec / tick_rate_hz) +//! - Buffer cap: one selected InputCmd per (player_id, tick) + +use std::collections::HashMap; + +use flowstate_sim::{PlayerId, Tick}; +use flowstate_wire::InputCmdProto; + +use crate::validation::{BufferResult, ValidationConfig}; + +/// Per-(player_id, tick) buffer entry. +#[derive(Debug, Clone)] +struct BufferEntry { + /// Selected InputCmd (the one with max InputSeq so far). + selected: InputCmdProto, + /// Maximum InputSeq observed. + max_input_seq: u64, + /// Whether max_input_seq was observed more than once (tie). + max_seq_tied: bool, + /// Number of inputs received for this (player_id, tick) in this tick window. + receive_count: u32, +} + +/// Input buffer for Server Edge. +/// +/// Buffers inputs by (player_id, tick) within the InputTickWindow. +pub struct InputBuffer { + config: ValidationConfig, + /// Buffer keyed by (player_id, tick). + buffer: HashMap<(PlayerId, Tick), BufferEntry>, + /// Per-tick rate limit = ceil(input_rate_limit_per_sec / tick_rate_hz). + per_tick_limit: u32, +} + +impl InputBuffer { + /// Create a new input buffer. + pub fn new(config: ValidationConfig) -> Self { + // per_tick_limit = ceil(input_rate_limit_per_sec / tick_rate_hz) + let per_tick_limit = config + .input_rate_limit_per_sec + .div_ceil(config.tick_rate_hz); + + Self { + config, + buffer: HashMap::new(), + per_tick_limit, + } + } + + /// Get the configuration. + pub fn config(&self) -> &ValidationConfig { + &self.config + } + + /// Try to buffer an input. + /// + /// Returns `BufferResult` indicating whether the input was accepted. + pub fn try_buffer(&mut self, player_id: PlayerId, input: InputCmdProto) -> BufferResult { + let key = (player_id, input.tick); + let input_seq = input.input_seq; + + // Check if we already have an entry for this (player_id, tick) + if let Some(entry) = self.buffer.get_mut(&key) { + // Rate limiting: check receive count + if entry.receive_count >= self.per_tick_limit { + return BufferResult::RateLimited; + } + entry.receive_count += 1; + + // InputSeq tie-breaking per spec: + // - seq > max: update to new max, clear tie flag + // - seq == max: set tie flag + // - seq < max: ignore for selection + if input_seq > entry.max_input_seq { + entry.max_input_seq = input_seq; + entry.max_seq_tied = false; + entry.selected = input; + } else if input_seq == entry.max_input_seq { + entry.max_seq_tied = true; + } + // else seq < max: ignore + + // Check for magnitude clamping + let clamped = needs_magnitude_clamp(&entry.selected.move_dir); + if clamped { + clamp_magnitude(&mut entry.selected.move_dir); + } + + BufferResult::Accepted { clamped } + } else { + // First input for this (player_id, tick) + let clamped = needs_magnitude_clamp(&input.move_dir); + let mut input = input; + if clamped { + clamp_magnitude(&mut input.move_dir); + } + + let entry = BufferEntry { + selected: input.clone(), + max_input_seq: input_seq, + max_seq_tied: false, + receive_count: 1, + }; + self.buffer.insert(key, entry); + + BufferResult::Accepted { clamped } + } + } + + /// Take the selected input for a (player_id, tick), removing it from the buffer. + /// + /// Returns `None` if: + /// - No input exists for this (player_id, tick) + /// - InputSeq was tied (per spec: use LastKnownIntent instead) + pub fn take_input(&mut self, player_id: PlayerId, tick: Tick) -> Option { + let key = (player_id, tick); + let entry = self.buffer.remove(&key)?; + + if entry.max_seq_tied { + // Tied InputSeq → drop and use LKI + None + } else { + Some(entry.selected) + } + } + + /// Evict all buffered entries for ticks before the given tick. + pub fn evict_before(&mut self, tick: Tick) { + self.buffer.retain(|&(_, t), _| t >= tick); + } + + /// Check if an entry exists (for testing). + #[cfg(test)] + pub fn has_entry(&self, player_id: PlayerId, tick: Tick) -> bool { + self.buffer.contains_key(&(player_id, tick)) + } +} + +/// Check if magnitude exceeds 1.0. +fn needs_magnitude_clamp(move_dir: &[f64]) -> bool { + if move_dir.len() != 2 { + return false; + } + let mag_sq = move_dir[0] * move_dir[0] + move_dir[1] * move_dir[1]; + mag_sq > 1.0 +} + +/// Clamp magnitude to 1.0 in place. +fn clamp_magnitude(move_dir: &mut [f64]) { + if move_dir.len() != 2 { + return; + } + let mag_sq = move_dir[0] * move_dir[0] + move_dir[1] * move_dir[1]; + if mag_sq > 1.0 { + let mag = mag_sq.sqrt(); + move_dir[0] /= mag; + move_dir[1] /= mag; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_input(tick: Tick, seq: u64, x: f64, y: f64) -> InputCmdProto { + InputCmdProto { + tick, + input_seq: seq, + move_dir: vec![x, y], + } + } + + #[test] + fn test_first_input_accepted() { + let mut buffer = InputBuffer::new(ValidationConfig::default()); + let input = make_input(5, 1, 1.0, 0.0); + + let result = buffer.try_buffer(0, input); + assert_eq!(result, BufferResult::Accepted { clamped: false }); + assert!(buffer.has_entry(0, 5)); + } + + #[test] + fn test_higher_seq_replaces() { + let mut buffer = InputBuffer::new(ValidationConfig::default()); + + // First input with seq 1 + buffer.try_buffer(0, make_input(5, 1, 1.0, 0.0)); + + // Second input with seq 2 (higher) + buffer.try_buffer(0, make_input(5, 2, 0.0, 1.0)); + + // Should have the second input + let taken = buffer.take_input(0, 5).unwrap(); + assert_eq!(taken.input_seq, 2); + assert_eq!(taken.move_dir, vec![0.0, 1.0]); + } + + #[test] + fn test_lower_seq_ignored() { + let mut buffer = InputBuffer::new(ValidationConfig::default()); + + // First input with seq 5 + buffer.try_buffer(0, make_input(5, 5, 1.0, 0.0)); + + // Second input with seq 3 (lower) + buffer.try_buffer(0, make_input(5, 3, 0.0, 1.0)); + + // Should still have first input + let taken = buffer.take_input(0, 5).unwrap(); + assert_eq!(taken.input_seq, 5); + assert_eq!(taken.move_dir, vec![1.0, 0.0]); + } + + #[test] + fn test_equal_seq_causes_tie() { + let mut buffer = InputBuffer::new(ValidationConfig::default()); + + // First input with seq 5 + buffer.try_buffer(0, make_input(5, 5, 1.0, 0.0)); + + // Second input with seq 5 (same - tie!) + buffer.try_buffer(0, make_input(5, 5, 0.0, 1.0)); + + // Should return None (tie → use LKI) + let taken = buffer.take_input(0, 5); + assert!(taken.is_none()); + } + + #[test] + fn test_tie_cleared_by_higher_seq() { + // Use a higher rate limit so we can send 3 inputs + let config = ValidationConfig { + max_future_ticks: 120, + input_rate_limit_per_sec: 180, // 3 per tick at 60hz + tick_rate_hz: 60, + }; + let mut buffer = InputBuffer::new(config); + + // Create a tie + buffer.try_buffer(0, make_input(5, 5, 1.0, 0.0)); + buffer.try_buffer(0, make_input(5, 5, 0.0, 1.0)); + + // Now send a higher seq + buffer.try_buffer(0, make_input(5, 8, 0.5, 0.5)); + + // Should have the seq 8 input (tie cleared) + let taken = buffer.take_input(0, 5).unwrap(); + assert_eq!(taken.input_seq, 8); + } + + /// T0.6, T0.13: Rate limiting - N > limit drops at least N-limit. + #[test] + fn test_rate_limiting() { + let config = ValidationConfig { + max_future_ticks: 120, + input_rate_limit_per_sec: 120, + tick_rate_hz: 60, + }; + let mut buffer = InputBuffer::new(config); + + // per_tick_limit = ceil(120/60) = 2 + // Send 5 inputs for the same (player, tick) + let mut accepted = 0; + let mut dropped = 0; + + for seq in 1..=5 { + let result = buffer.try_buffer(0, make_input(5, seq, 1.0, 0.0)); + if result == BufferResult::RateLimited { + dropped += 1; + } else { + accepted += 1; + } + } + + // Should accept 2, drop 3 + assert_eq!(accepted, 2); + assert_eq!(dropped, 3); + } + + #[test] + fn test_magnitude_clamping() { + let mut buffer = InputBuffer::new(ValidationConfig::default()); + + // Input with magnitude > 1 + let input = make_input(5, 1, 2.0, 0.0); + let result = buffer.try_buffer(0, input); + + assert_eq!(result, BufferResult::Accepted { clamped: true }); + + let taken = buffer.take_input(0, 5).unwrap(); + // Should be clamped to unit length + let mag = (taken.move_dir[0].powi(2) + taken.move_dir[1].powi(2)).sqrt(); + assert!((mag - 1.0).abs() < 1e-10); + } + + #[test] + fn test_eviction() { + let mut buffer = InputBuffer::new(ValidationConfig::default()); + + buffer.try_buffer(0, make_input(5, 1, 1.0, 0.0)); + buffer.try_buffer(0, make_input(10, 1, 1.0, 0.0)); + buffer.try_buffer(0, make_input(15, 1, 1.0, 0.0)); + + // Evict before tick 10 + buffer.evict_before(10); + + assert!(!buffer.has_entry(0, 5)); + assert!(buffer.has_entry(0, 10)); + assert!(buffer.has_entry(0, 15)); + } + + /// T0.11: Future input non-interference. + #[test] + fn test_t0_11_future_input_buffered() { + let mut buffer = InputBuffer::new(ValidationConfig::default()); + + // Buffer input for tick 5 (future) + buffer.try_buffer(0, make_input(5, 1, 1.0, 0.0)); + + // Should still be there + assert!(buffer.has_entry(0, 5)); + + // Taking input for tick 0 should return None (not 5) + assert!(buffer.take_input(0, 0).is_none()); + + // Tick 5 should still be available + assert!(buffer.take_input(0, 5).is_some()); + } + + /// T0.13: InputSeq selection (tied → LKI fallback). + #[test] + fn test_t0_13_inputseq_tie_uses_lki() { + let mut buffer = InputBuffer::new(ValidationConfig::default()); + + // Send two inputs with same seq + buffer.try_buffer(0, make_input(5, 10, 1.0, 0.0)); + buffer.try_buffer(0, make_input(5, 10, 0.0, 1.0)); + + // take_input should return None (use LKI) + let result = buffer.take_input(0, 5); + assert!(result.is_none()); + } +} diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs new file mode 100644 index 0000000..f8a4d4b --- /dev/null +++ b/crates/server/src/lib.rs @@ -0,0 +1,785 @@ +//! Flowstate Server Edge +//! +//! The Server Edge mediates communication between Game Clients and the +//! Simulation Core. It owns: +//! - Session management (DM-0008) +//! - Input validation and buffering +//! - TargetTickFloor computation (ADR-0006) +//! - AppliedInput → StepInput conversion +//! - Replay recording +//! +//! # Architecture (INV-0003, INV-0004) +//! +//! The Server Edge performs all I/O on behalf of the Game Server Instance. +//! The Simulation Core is invoked only with StepInput and produces Snapshots. +//! +//! # References +//! +//! - INV-0003: Authoritative Simulation +//! - INV-0004: Simulation Core Isolation +//! - INV-0005: Tick-Indexed I/O Contract +//! - ADR-0005: v0 Networking Architecture +//! - ADR-0006: Input Tick Targeting +//! - DM-0011: Server Edge + +#![deny(unsafe_code)] + +pub mod input_buffer; +pub mod session; +pub mod validation; + +use std::collections::HashMap; + +use flowstate_replay::{AppliedInput, BuildFingerprintData, ReplayConfig, ReplayRecorder}; +use flowstate_sim::{Baseline, PlayerId, Snapshot, StepInput, Tick, World}; +use flowstate_wire::{InputCmdProto, JoinBaseline, ReplayArtifact, ServerWelcome, SnapshotProto}; +use input_buffer::InputBuffer; +use session::{Session, SessionId}; +use validation::{ValidationConfig, ValidationResult, validate_input}; + +// ============================================================================ +// v0 Parameters (from docs/networking/v0-parameters.md) +// ============================================================================ + +/// v0 tick rate in Hz. +pub const TICK_RATE_HZ: u32 = 60; + +/// Maximum ticks ahead a client can target. +pub const MAX_FUTURE_TICKS: u64 = 120; + +/// TargetTickFloor lead. +pub const INPUT_LEAD_TICKS: u64 = 1; + +/// Input rate limit per second. +pub const INPUT_RATE_LIMIT_PER_SEC: u32 = 120; + +/// Match duration in ticks. +pub const MATCH_DURATION_TICKS: u64 = 3600; + +/// Connection timeout in milliseconds. +pub const CONNECT_TIMEOUT_MS: u64 = 30000; + +// ============================================================================ +// Match End Reason +// ============================================================================ + +/// Reason for match termination. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EndReason { + Complete, + Disconnect, +} + +impl EndReason { + pub fn as_str(&self) -> &'static str { + match self { + Self::Complete => "complete", + Self::Disconnect => "disconnect", + } + } +} + +// ============================================================================ +// Server State +// ============================================================================ + +/// Server configuration. +#[derive(Debug, Clone)] +pub struct ServerConfig { + pub seed: u64, + pub tick_rate_hz: u32, + pub max_future_ticks: u64, + pub input_lead_ticks: u64, + pub input_rate_limit_per_sec: u32, + pub match_duration_ticks: u64, + pub connect_timeout_ms: u64, + pub test_mode: bool, + pub test_player_ids: Option<(PlayerId, PlayerId)>, +} + +impl Default for ServerConfig { + fn default() -> Self { + Self { + seed: 0, + tick_rate_hz: TICK_RATE_HZ, + max_future_ticks: MAX_FUTURE_TICKS, + input_lead_ticks: INPUT_LEAD_TICKS, + input_rate_limit_per_sec: INPUT_RATE_LIMIT_PER_SEC, + match_duration_ticks: MATCH_DURATION_TICKS, + connect_timeout_ms: CONNECT_TIMEOUT_MS, + test_mode: false, + test_player_ids: None, + } + } +} + +/// Server state for running a match. +pub struct Server { + config: ServerConfig, + world: World, + sessions: HashMap, + next_session_id: SessionId, + /// PlayerId → SessionId mapping + player_sessions: HashMap, + /// SessionId → PlayerId mapping (for convenience) + session_players: HashMap, + /// Input buffer per (player_id, tick) + input_buffer: InputBuffer, + /// Last known intent per player + last_known_intent: HashMap, + /// Last emitted target tick floor per session + last_emitted_floor: HashMap, + /// Replay recorder + replay_recorder: ReplayRecorder, + /// Entity spawn order (player_ids in order) + entity_spawn_order: Vec, + /// Player → Entity mapping + player_entity_mapping: HashMap, + /// Initial tick (set after match starts) + initial_tick: Tick, + /// Match started flag + match_started: bool, + /// Build fingerprint + build_fingerprint: Option, +} + +impl Server { + /// Create a new server with the given configuration. + pub fn new(config: ServerConfig) -> Self { + let validation_config = ValidationConfig { + max_future_ticks: config.max_future_ticks, + input_rate_limit_per_sec: config.input_rate_limit_per_sec, + tick_rate_hz: config.tick_rate_hz, + }; + + let replay_config = ReplayConfig { + seed: config.seed, + tick_rate_hz: config.tick_rate_hz, + rng_algorithm: "none".to_string(), + test_mode: config.test_mode, + test_player_ids: config + .test_player_ids + .map(|(a, b)| vec![a, b]) + .unwrap_or_default(), + }; + + Self { + world: World::new(config.seed, config.tick_rate_hz), + sessions: HashMap::new(), + next_session_id: 1, + player_sessions: HashMap::new(), + session_players: HashMap::new(), + input_buffer: InputBuffer::new(validation_config), + last_known_intent: HashMap::new(), + last_emitted_floor: HashMap::new(), + replay_recorder: ReplayRecorder::new(replay_config), + entity_spawn_order: Vec::new(), + player_entity_mapping: HashMap::new(), + initial_tick: 0, + match_started: false, + build_fingerprint: None, + config, + } + } + + /// Set the build fingerprint. + pub fn set_build_fingerprint(&mut self, fingerprint: BuildFingerprintData) { + self.build_fingerprint = Some(fingerprint.clone()); + self.replay_recorder.set_build_fingerprint(fingerprint); + } + + /// Get current tick. + pub fn current_tick(&self) -> Tick { + self.world.tick() + } + + /// Get number of connected sessions. + pub fn session_count(&self) -> usize { + self.sessions.len() + } + + /// Check if server is ready to start (enough sessions connected). + /// Used for external timeout enforcement (T0.16). + pub fn is_ready_to_start(&self) -> bool { + self.sessions.len() >= 2 + } + + /// Accept a new session (client connected). + /// Returns (session_id, assigned_player_id, controlled_entity_id). + /// + /// # Panics + /// If more than 2 sessions try to connect (v0 limit). + pub fn accept_session(&mut self) -> (SessionId, PlayerId, flowstate_sim::EntityId) { + assert!(self.sessions.len() < 2, "v0: Only 2 sessions allowed"); + assert!( + !self.match_started, + "Cannot accept sessions after match start" + ); + + let session_id = self.next_session_id; + self.next_session_id += 1; + + // Assign player ID + let player_id = if let Some((id1, id2)) = self.config.test_player_ids { + // Test mode: use configured IDs + if self.sessions.is_empty() { id1 } else { id2 } + } else { + // Normal mode: 0 for first, 1 for second + self.sessions.len() as PlayerId + }; + + // Spawn character + let entity_id = self.world.spawn_character(player_id); + + // Create session + let session = Session::new(session_id, player_id, entity_id); + self.sessions.insert(session_id, session); + self.player_sessions.insert(player_id, session_id); + self.session_players.insert(session_id, player_id); + + // Record spawn order + self.entity_spawn_order.push(player_id); + self.player_entity_mapping.insert(player_id, entity_id); + self.replay_recorder.record_spawn(player_id, entity_id); + + // Initialize last known intent + self.last_known_intent.insert(player_id, [0.0, 0.0]); + + (session_id, player_id, entity_id) + } + + /// Start the match (after 2 clients connected). + /// Returns the initial baseline and ServerWelcome data for each session. + pub fn start_match(&mut self) -> (Baseline, Vec<(SessionId, ServerWelcome)>) { + assert_eq!( + self.sessions.len(), + 2, + "Need exactly 2 sessions to start match" + ); + assert!(!self.match_started, "Match already started"); + + self.match_started = true; + self.initial_tick = self.world.tick(); + + // Record baseline + let baseline = self.world.baseline(); + self.replay_recorder.record_baseline(baseline.clone()); + + // Compute initial target tick floor + let target_tick_floor = self.initial_tick + self.config.input_lead_ticks; + + // Initialize floor state for all sessions + for &session_id in self.sessions.keys() { + self.last_emitted_floor + .insert(session_id, target_tick_floor); + } + + // Create ServerWelcome for each session + let welcomes: Vec<_> = self + .sessions + .values() + .map(|session| { + let welcome = ServerWelcome { + target_tick_floor, + tick_rate_hz: self.config.tick_rate_hz, + player_id: u32::from(session.player_id), + controlled_entity_id: session.controlled_entity_id, + }; + (session.id, welcome) + }) + .collect(); + + (baseline, welcomes) + } + + /// Check if match should end. + pub fn should_end_match(&self) -> Option { + if !self.match_started { + return None; + } + + // Check duration + if self.world.tick() >= self.initial_tick + self.config.match_duration_ticks { + return Some(EndReason::Complete); + } + + None + } + + /// Handle session disconnect. + pub fn disconnect_session(&mut self, session_id: SessionId) { + if let Some(session) = self.sessions.remove(&session_id) { + self.player_sessions.remove(&session.player_id); + self.session_players.remove(&session_id); + } + } + + /// Check if any session has disconnected. + pub fn has_disconnect(&self) -> bool { + // In v0, we check if we started with 2 and now have fewer + self.match_started && self.sessions.len() < 2 + } + + /// Receive and buffer an input from a client. + /// Returns validation result. + pub fn receive_input( + &mut self, + session_id: SessionId, + input: InputCmdProto, + ) -> ValidationResult { + // Pre-Welcome input drop + if !self.match_started { + return ValidationResult::DroppedPreWelcome; + } + + // Get player_id for this session + let Some(&player_id) = self.session_players.get(&session_id) else { + return ValidationResult::DroppedUnknownSession; + }; + + // Get last emitted floor for this session + let floor = self + .last_emitted_floor + .get(&session_id) + .copied() + .unwrap_or(0); + + // Validate input + validate_input( + &input, + self.world.tick(), + floor, + &mut self.input_buffer, + player_id, + ) + } + + /// Process a single tick. + /// Returns (snapshot, target_tick_floor, serialized_snapshot_bytes). + /// + /// The serialized bytes are identical for all sessions (T0.18). + pub fn step(&mut self) -> (Snapshot, Tick, Vec) { + let current_tick = self.world.tick(); + + // Produce AppliedInput per player + let mut applied_inputs: Vec = Vec::new(); + + for &player_id in self.entity_spawn_order.iter() { + let (move_dir, is_fallback) = self + .input_buffer + .take_input(player_id, current_tick) + .map(|cmd| { + // Validate and normalize move_dir + let move_dir = if cmd.move_dir.len() == 2 { + [cmd.move_dir[0], cmd.move_dir[1]] + } else { + [0.0, 0.0] + }; + (move_dir, false) + }) + .unwrap_or_else(|| { + // LastKnownIntent fallback + let lki = self + .last_known_intent + .get(&player_id) + .copied() + .unwrap_or([0.0, 0.0]); + (lki, true) + }); + + // Update last known intent + self.last_known_intent.insert(player_id, move_dir); + + applied_inputs.push(AppliedInput { + tick: current_tick, + player_id, + move_dir, + is_fallback, + }); + } + + // Record for replay + for input in &applied_inputs { + self.replay_recorder.record_input(input.clone()); + } + + // Convert to StepInput (sorted by player_id) + let mut step_inputs: Vec = applied_inputs + .iter() + .map(AppliedInput::to_step_input) + .collect(); + step_inputs.sort_by_key(|i| i.player_id); + + // Advance world + let snapshot = self.world.advance(current_tick, &step_inputs); + + // Compute new target tick floor (post-step tick + lead) + let target_tick_floor = self.world.tick() + self.config.input_lead_ticks; + + // Update floor for all sessions + for session_id in self.sessions.keys() { + self.last_emitted_floor + .insert(*session_id, target_tick_floor); + } + + // Evict old buffered inputs + self.input_buffer.evict_before(self.world.tick()); + + // Serialize snapshot (identical for all sessions - T0.18) + let snapshot_proto = SnapshotProto { + tick: snapshot.tick, + entities: snapshot + .entities + .iter() + .map(|e| flowstate_wire::EntitySnapshotProto { + entity_id: e.entity_id, + position: e.position.to_vec(), + velocity: e.velocity.to_vec(), + }) + .collect(), + digest: snapshot.digest, + target_tick_floor, + }; + let snapshot_bytes = prost::Message::encode_to_vec(&snapshot_proto); + + (snapshot, target_tick_floor, snapshot_bytes) + } + + /// Finalize the match and produce a replay artifact. + pub fn finalize(self, end_reason: EndReason) -> ReplayArtifact { + let final_digest = self.world.state_digest(); + let checkpoint_tick = self.world.tick(); + + self.replay_recorder + .finalize(final_digest, checkpoint_tick, end_reason.as_str()) + } + + /// Get the baseline for JoinBaseline message. + pub fn baseline_proto(&self) -> JoinBaseline { + let baseline = self.world.baseline(); + baseline.into() + } + + /// Get all connected session IDs. + pub fn session_ids(&self) -> Vec { + self.sessions.keys().copied().collect() + } +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + /// T0.1: Two clients connect, complete handshake. + #[test] + fn test_t0_01_two_client_handshake() { + let mut server = Server::new(ServerConfig::default()); + + // Accept first session + let (session1, player1, entity1) = server.accept_session(); + assert_eq!(player1, 0); + assert!(entity1 > 0); + assert_eq!(server.session_count(), 1); + + // Accept second session + let (_session2, player2, entity2) = server.accept_session(); + assert_eq!(player2, 1); + assert!(entity2 > 0); + assert_ne!(entity1, entity2); + assert_eq!(server.session_count(), 2); + + // Start match + let (baseline, welcomes) = server.start_match(); + assert_eq!(baseline.tick, 0); + assert_eq!(welcomes.len(), 2); + + // Verify ServerWelcome contents + for (sid, welcome) in &welcomes { + assert_eq!(welcome.target_tick_floor, INPUT_LEAD_TICKS); + assert_eq!(welcome.tick_rate_hz, TICK_RATE_HZ); + if *sid == session1 { + assert_eq!(welcome.player_id, 0); + assert_eq!(welcome.controlled_entity_id, entity1); + } else { + assert_eq!(welcome.player_id, 1); + assert_eq!(welcome.controlled_entity_id, entity2); + } + } + } + + /// T0.2: JoinBaseline delivers initial Baseline. + #[test] + fn test_t0_02_join_baseline() { + let mut server = Server::new(ServerConfig::default()); + server.accept_session(); + server.accept_session(); + + let (baseline, _) = server.start_match(); + + // Baseline should have 2 entities at tick 0 + assert_eq!(baseline.tick, 0); + assert_eq!(baseline.entities.len(), 2); + assert!(baseline.digest != 0); + } + + /// T0.5a: Tick/floor relationship assertion. + #[test] + fn test_t0_05a_tick_floor_relationship() { + let mut server = Server::new(ServerConfig::default()); + server.accept_session(); + server.accept_session(); + server.start_match(); + + // Step once + let (snapshot, floor, _) = server.step(); + + // After advance(0, inputs), snapshot.tick should be 1 + assert_eq!(snapshot.tick, 1); + // Floor should be post-step tick + lead = 1 + 1 = 2 + assert_eq!(floor, 1 + INPUT_LEAD_TICKS); + + // Step again + let (snapshot2, floor2, _) = server.step(); + assert_eq!(snapshot2.tick, 2); + assert_eq!(floor2, 2 + INPUT_LEAD_TICKS); + } + + /// T0.14: Disconnect handling. + #[test] + fn test_t0_14_disconnect_handling() { + let mut server = Server::new(ServerConfig::default()); + let (session1, _, _) = server.accept_session(); + server.accept_session(); + server.start_match(); + + // Simulate disconnect + server.disconnect_session(session1); + + assert!(server.has_disconnect()); + assert_eq!(server.session_count(), 1); + } + + /// T0.15: Match termination. + #[test] + fn test_t0_15_match_termination() { + let config = ServerConfig { + match_duration_ticks: 10, // Short match for testing + ..Default::default() + }; + let mut server = Server::new(config); + server.accept_session(); + server.accept_session(); + server.start_match(); + + // Run until match should end + for _ in 0..10 { + assert!(server.should_end_match().is_none()); + server.step(); + } + + assert_eq!(server.should_end_match(), Some(EndReason::Complete)); + } + + /// T0.17: PlayerId non-assumption (test mode). + #[test] + fn test_t0_17_playerid_test_mode() { + let config = ServerConfig { + test_mode: true, + test_player_ids: Some((17, 99)), + match_duration_ticks: 10, + ..Default::default() + }; + let mut server = Server::new(config); + + let (_, player1, _) = server.accept_session(); + let (_, player2, _) = server.accept_session(); + + assert_eq!(player1, 17); + assert_eq!(player2, 99); + + server.start_match(); + + // Run a few ticks + for _ in 0..5 { + server.step(); + } + + // Finalize and check artifact + let artifact = server.finalize(EndReason::Complete); + assert!(artifact.test_mode); + assert_eq!(artifact.test_player_ids, vec![17, 99]); + assert_eq!(artifact.entity_spawn_order, vec![17, 99]); + } + + /// T0.18: Floor coherency - byte-identical broadcasts. + #[test] + fn test_t0_18_floor_coherency_broadcast() { + let mut server = Server::new(ServerConfig::default()); + server.accept_session(); + server.accept_session(); + server.start_match(); + + // Step and get serialized snapshot + let (_, floor1, bytes1) = server.step(); + + // The bytes would be sent to all sessions identically + // Decode to verify floor is consistent + let decoded: SnapshotProto = prost::Message::decode(bytes1.as_slice()).unwrap(); + assert_eq!(decoded.target_tick_floor, floor1); + + // Step again + let (_, floor2, bytes2) = server.step(); + let decoded2: SnapshotProto = prost::Message::decode(bytes2.as_slice()).unwrap(); + assert_eq!(decoded2.target_tick_floor, floor2); + assert!(floor2 > floor1, "Floor should be monotonic increasing"); + } + + /// T0.12: LastKnownIntent determinism - empty inputs use LKI. + #[test] + fn test_t0_12_lki_fallback() { + let config = ServerConfig { + match_duration_ticks: 10, + ..Default::default() + }; + let mut server = Server::new(config); + server.accept_session(); + server.accept_session(); + server.start_match(); + + // Step without any inputs - should use LKI (zero) + let (snapshot1, _, _) = server.step(); + + // All entities should be at origin (no movement with zero LKI) + for entity in &snapshot1.entities { + assert_eq!(entity.position, [0.0, 0.0]); + } + + // Now finalize and verify artifact has fallback inputs + let artifact = server.finalize(EndReason::Complete); + + // All inputs should be fallback since we didn't send any + assert!(artifact.inputs.iter().all(|i| i.is_fallback)); + } + + /// Test replay artifact generation. + #[test] + fn test_replay_artifact_generation() { + let config = ServerConfig { + match_duration_ticks: 5, + ..Default::default() + }; + let mut server = Server::new(config); + server.accept_session(); + server.accept_session(); + server.start_match(); + + // Run the match + while server.should_end_match().is_none() { + server.step(); + } + + let artifact = server.finalize(EndReason::Complete); + + assert_eq!(artifact.replay_format_version, 1); + assert!(artifact.initial_baseline.is_some()); + assert_eq!(artifact.tick_rate_hz, 60); + assert_eq!(artifact.checkpoint_tick, 5); + assert_eq!(artifact.end_reason, "complete"); + // 5 ticks * 2 players = 10 inputs + assert_eq!(artifact.inputs.len(), 10); + } + + /// T0.13a: Floor enforcement and recovery. + /// + /// Simulates a scenario where inputs are submitted below floor (as if + /// snapshot packets were lost). Verifies these are dropped, then + /// "recovery" occurs when inputs target future ticks again. + #[test] + fn test_t0_13a_floor_enforcement_recovery() { + let config = ServerConfig { + match_duration_ticks: 20, + ..Default::default() + }; + let mut server = Server::new(config); + let (session1, _, _) = server.accept_session(); + server.accept_session(); + let (_, welcomes) = server.start_match(); + + // Get initial floor (verified for sanity) + let initial_floor = welcomes[0].1.target_tick_floor; + assert_eq!(initial_floor, INPUT_LEAD_TICKS); + + // Step a few times to advance the floor + for _ in 0..5 { + server.step(); + } + + // Floor should now be higher + let current_tick = 5; + let current_floor = current_tick + INPUT_LEAD_TICKS; + + // Try to submit an input targeting OLD tick (below floor) - should be dropped + let stale_input = InputCmdProto { + tick: 2, // Way below current floor + input_seq: 1, + move_dir: vec![1.0, 0.0], + }; + let result = server.receive_input(session1, stale_input); + assert!( + matches!(result, ValidationResult::DroppedBelowFloor { .. }), + "Input below floor should be dropped: {:?}", + result + ); + + // Now submit a valid input targeting current floor - should be accepted + let valid_input = InputCmdProto { + tick: current_floor, + input_seq: 2, + move_dir: vec![1.0, 0.0], + }; + let result = server.receive_input(session1, valid_input); + assert!( + result.is_accepted(), + "Input at floor should be accepted: {:?}", + result + ); + } + + /// T0.16: Connection timeout. + /// + /// Server should detect when connection phase exceeds timeout. + /// Note: In v0, actual timeout is external (e.g., orchestrator checks). + /// This test verifies the timeout constant exists and server exposes + /// connection state for external timeout enforcement. + #[test] + fn test_t0_16_connection_timeout() { + // Verify timeout constant is set per v0-parameters + assert_eq!(CONNECT_TIMEOUT_MS, 30000); + + // Create server and verify session tracking + let mut server = Server::new(ServerConfig::default()); + assert_eq!(server.session_count(), 0); + assert!(!server.is_ready_to_start()); + + // Add one session - not ready + server.accept_session(); + assert_eq!(server.session_count(), 1); + assert!(!server.is_ready_to_start()); + + // Add second session - now ready + server.accept_session(); + assert_eq!(server.session_count(), 2); + assert!(server.is_ready_to_start()); + + // The timeout itself would be enforced externally by checking: + // - start_time (when server was created) + // - current_time - start_time > CONNECT_TIMEOUT_MS + // - server.is_ready_to_start() == false + // If that condition is true, orchestrator would exit with non-zero. + // The server exposes enough state for this check. + } +} diff --git a/crates/server/src/session.rs b/crates/server/src/session.rs new file mode 100644 index 0000000..543ad4b --- /dev/null +++ b/crates/server/src/session.rs @@ -0,0 +1,33 @@ +//! Session management for Server Edge. +//! +//! Ref: DM-0008 (Session) + +use flowstate_sim::{EntityId, PlayerId}; + +/// Session identifier (server-internal). +pub type SessionId = u64; + +/// Client session state. +#[derive(Debug, Clone)] +pub struct Session { + pub id: SessionId, + pub player_id: PlayerId, + pub controlled_entity_id: EntityId, + /// Last valid input tick received from this session (for monotonicity check). + pub last_valid_tick: Option, + /// Last input_seq received from this session. + pub last_input_seq: Option, +} + +impl Session { + /// Create a new session. + pub fn new(id: SessionId, player_id: PlayerId, controlled_entity_id: EntityId) -> Self { + Self { + id, + player_id, + controlled_entity_id, + last_valid_tick: None, + last_input_seq: None, + } + } +} diff --git a/crates/server/src/validation.rs b/crates/server/src/validation.rs new file mode 100644 index 0000000..edff3da --- /dev/null +++ b/crates/server/src/validation.rs @@ -0,0 +1,264 @@ +//! Input validation for Server Edge. +//! +//! Ref: FS-0007 Validation Rules +//! - NaN/Inf in move_dir: DROP + LOG +//! - Magnitude > 1.0: CLAMP + LOG +//! - Tick below floor: DROP +//! - Tick non-monotonic: DROP +//! - Tick window violation: DROP +//! - Rate limit exceeded: DROP + +use flowstate_sim::{PlayerId, Tick}; +use flowstate_wire::InputCmdProto; + +use crate::input_buffer::InputBuffer; + +/// Validation configuration. +#[derive(Debug, Clone, Copy)] +pub struct ValidationConfig { + pub max_future_ticks: u64, + pub input_rate_limit_per_sec: u32, + pub tick_rate_hz: u32, +} + +impl Default for ValidationConfig { + fn default() -> Self { + Self { + max_future_ticks: 120, + input_rate_limit_per_sec: 120, + tick_rate_hz: 60, + } + } +} + +/// Result of input validation. +#[derive(Debug, Clone, PartialEq)] +pub enum ValidationResult { + /// Input accepted and buffered. + Accepted, + /// Input accepted with magnitude clamped. + AcceptedWithClamp, + /// Dropped: NaN or Inf in move_dir. + DroppedNanInf, + /// Dropped: Tick below target tick floor. + DroppedBelowFloor { tick: Tick, floor: Tick }, + /// Dropped: Tick is late (below current tick). + DroppedLate { tick: Tick, current: Tick }, + /// Dropped: Tick is too far in future. + DroppedTooFuture { tick: Tick, max: Tick }, + /// Dropped: Rate limit exceeded. + DroppedRateLimit, + /// Dropped: InputSeq tied for this (player, tick). + DroppedInputSeqTie, + /// Dropped: Received before ServerWelcome. + DroppedPreWelcome, + /// Dropped: Unknown session. + DroppedUnknownSession, +} + +impl ValidationResult { + pub fn is_accepted(&self) -> bool { + matches!(self, Self::Accepted | Self::AcceptedWithClamp) + } +} + +/// Validate an input command. +/// +/// # Arguments +/// * `input` - The input command to validate +/// * `current_tick` - Current server tick +/// * `target_tick_floor` - Last emitted target tick floor for this session +/// * `buffer` - Input buffer for rate limiting and InputSeq selection +/// * `player_id` - Player ID for this session (bound by Server Edge, not from input) +pub fn validate_input( + input: &InputCmdProto, + current_tick: Tick, + target_tick_floor: Tick, + buffer: &mut InputBuffer, + player_id: PlayerId, +) -> ValidationResult { + // Check for NaN/Inf + if input.move_dir.len() != 2 { + return ValidationResult::DroppedNanInf; + } + let (x, y) = (input.move_dir[0], input.move_dir[1]); + if x.is_nan() || x.is_infinite() || y.is_nan() || y.is_infinite() { + return ValidationResult::DroppedNanInf; + } + + // Check tick below floor + if input.tick < target_tick_floor { + return ValidationResult::DroppedBelowFloor { + tick: input.tick, + floor: target_tick_floor, + }; + } + + // Check tick is late + if input.tick < current_tick { + return ValidationResult::DroppedLate { + tick: input.tick, + current: current_tick, + }; + } + + // Check tick is too far in future + let max_tick = current_tick + buffer.config().max_future_ticks; + if input.tick > max_tick { + return ValidationResult::DroppedTooFuture { + tick: input.tick, + max: max_tick, + }; + } + + // Check rate limit and buffer + match buffer.try_buffer(player_id, input.clone()) { + BufferResult::Accepted { clamped } => { + if clamped { + ValidationResult::AcceptedWithClamp + } else { + ValidationResult::Accepted + } + } + BufferResult::RateLimited => ValidationResult::DroppedRateLimit, + BufferResult::InputSeqTie => ValidationResult::DroppedInputSeqTie, + } +} + +/// Result of attempting to buffer an input. +#[derive(Debug, Clone, PartialEq)] +pub enum BufferResult { + Accepted { clamped: bool }, + RateLimited, + InputSeqTie, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_valid_input(tick: Tick, seq: u64) -> InputCmdProto { + InputCmdProto { + tick, + input_seq: seq, + move_dir: vec![1.0, 0.0], + } + } + + #[test] + fn test_nan_rejection() { + let mut buffer = InputBuffer::new(ValidationConfig::default()); + let input = InputCmdProto { + tick: 5, + input_seq: 1, + move_dir: vec![f64::NAN, 0.0], + }; + + let result = validate_input(&input, 0, 0, &mut buffer, 0); + assert_eq!(result, ValidationResult::DroppedNanInf); + } + + #[test] + fn test_inf_rejection() { + let mut buffer = InputBuffer::new(ValidationConfig::default()); + let input = InputCmdProto { + tick: 5, + input_seq: 1, + move_dir: vec![0.0, f64::INFINITY], + }; + + let result = validate_input(&input, 0, 0, &mut buffer, 0); + assert_eq!(result, ValidationResult::DroppedNanInf); + } + + #[test] + fn test_below_floor_rejection() { + let mut buffer = InputBuffer::new(ValidationConfig::default()); + let input = make_valid_input(5, 1); + + // Floor is 10, input targets 5 + let result = validate_input(&input, 0, 10, &mut buffer, 0); + assert!(matches!(result, ValidationResult::DroppedBelowFloor { .. })); + } + + #[test] + fn test_late_rejection() { + let mut buffer = InputBuffer::new(ValidationConfig::default()); + let input = make_valid_input(5, 1); + + // Current tick is 10, input targets 5 + let result = validate_input(&input, 10, 0, &mut buffer, 0); + assert!(matches!(result, ValidationResult::DroppedLate { .. })); + } + + #[test] + fn test_too_future_rejection() { + let config = ValidationConfig { + max_future_ticks: 10, + ..Default::default() + }; + let mut buffer = InputBuffer::new(config); + let input = make_valid_input(100, 1); + + // Current tick is 0, max is 0+10=10, input targets 100 + let result = validate_input(&input, 0, 0, &mut buffer, 0); + assert!(matches!(result, ValidationResult::DroppedTooFuture { .. })); + } + + #[test] + fn test_valid_input_accepted() { + let mut buffer = InputBuffer::new(ValidationConfig::default()); + let input = make_valid_input(5, 1); + + let result = validate_input(&input, 0, 0, &mut buffer, 0); + assert!(result.is_accepted()); + } + + /// T0.7: Malformed inputs do not crash server. + #[test] + fn test_t0_07_malformed_inputs_no_crash() { + let mut buffer = InputBuffer::new(ValidationConfig::default()); + + // Empty move_dir + let input1 = InputCmdProto { + tick: 5, + input_seq: 1, + move_dir: vec![], + }; + let _ = validate_input(&input1, 0, 0, &mut buffer, 0); + + // Single element move_dir + let input2 = InputCmdProto { + tick: 5, + input_seq: 2, + move_dir: vec![1.0], + }; + let _ = validate_input(&input2, 0, 0, &mut buffer, 0); + + // NaN + let input3 = InputCmdProto { + tick: 5, + input_seq: 3, + move_dir: vec![f64::NAN, f64::NAN], + }; + let _ = validate_input(&input3, 0, 0, &mut buffer, 0); + + // Negative infinity + let input4 = InputCmdProto { + tick: 5, + input_seq: 4, + move_dir: vec![f64::NEG_INFINITY, f64::NEG_INFINITY], + }; + let _ = validate_input(&input4, 0, 0, &mut buffer, 0); + + // Huge magnitude + let input5 = InputCmdProto { + tick: 5, + input_seq: 5, + move_dir: vec![1e308, 1e308], + }; + let _ = validate_input(&input5, 0, 0, &mut buffer, 0); + + // All handled without panic + } +} diff --git a/crates/sim/src/lib.rs b/crates/sim/src/lib.rs index 32ed938..ad4d469 100644 --- a/crates/sim/src/lib.rs +++ b/crates/sim/src/lib.rs @@ -1,24 +1,798 @@ -//! Flowstate simulation kernel (stub). +//! Flowstate Simulation Core //! -//! This is an intentionally minimal placeholder. +//! This crate contains the deterministic, fixed-timestep game simulation. +//! It is the authoritative source of truth for all game-outcome-affecting state. +//! +//! # Architecture Constraints (INV-0004, KC-0001) +//! +//! The Simulation Core MUST NOT: +//! - Perform I/O operations (file, network, etc.) +//! - Read wall-clock time +//! - Use ambient/unseeded randomness +//! - Make system calls +//! - Depend on frame rate or variable delta time +//! +//! All external communication occurs through explicit, serializable message +//! boundaries owned by the Server Edge (DM-0011). +//! +//! # References +//! +//! - INV-0001: Deterministic Simulation +//! - INV-0002: Fixed Timestep +//! - INV-0004: Simulation Core Isolation +//! - INV-0007: Deterministic Ordering & Canonicalization +//! - DM-0014: Simulation Core +//! - ADR-0007: StateDigest Algorithm + +#![deny(unsafe_code)] + +// ============================================================================ +// Type Aliases (Ref: DM-0001, DM-0019, DM-0020) +// ============================================================================ + +/// A single discrete simulation timestep; the atomic unit of game time. +/// Ref: DM-0001 +pub type Tick = u64; + +/// Per-Match participant identifier used for deterministic ordering. +/// Ref: DM-0019 +/// +/// NORMATIVE CONSTRAINT: Simulation Core MUST NOT assume PlayerIds are +/// contiguous, zero-based, or start at specific literal values (e.g., {0,1}). +/// PlayerId is used only as a stable indexing/ordering key. +pub type PlayerId = u8; + +/// Unique identifier for an Entity within a Match. +/// Ref: DM-0020 +pub type EntityId = u64; + +// ============================================================================ +// Core Types +// ============================================================================ + +/// Simulation-plane input consumed by advance(). +/// Ref: DM-0027 +/// +/// `player_id` is an association key used to match intent to player's entity; +/// Server Edge owns identity binding (INV-0003). +/// +/// StepInput values passed to advance() MUST be sorted by player_id ascending +/// for deterministic iteration (INV-0007). +#[derive(Debug, Clone, PartialEq)] +pub struct StepInput { + pub player_id: PlayerId, + /// Movement direction, magnitude <= 1.0 + pub move_dir: [f64; 2], +} + +/// Snapshot of a single entity's state. +/// Used in both Baseline and Snapshot. +#[derive(Debug, Clone, PartialEq)] +pub struct EntitySnapshot { + pub entity_id: EntityId, + pub position: [f64; 2], + pub velocity: [f64; 2], +} + +/// Pre-step world state at tick T. +/// Ref: DM-0016 +/// +/// Digest computed via World::state_digest() per ADR-0007. +/// entities MUST be sorted by entity_id ascending (INV-0007). +#[derive(Debug, Clone, PartialEq)] +pub struct Baseline { + pub tick: Tick, + pub entities: Vec, + pub digest: u64, +} + +/// Post-step world state at tick T+1. +/// Ref: DM-0007 +/// +/// After `world.advance(T, inputs)`, returned Snapshot has `snapshot.tick = T+1`. +/// Digest computed via World::state_digest() per ADR-0007. +/// entities MUST be sorted by entity_id ascending (INV-0007). +#[derive(Debug, Clone, PartialEq)] +pub struct Snapshot { + pub tick: Tick, + pub entities: Vec, + pub digest: u64, +} + +// ============================================================================ +// v0 Movement Model Constants (Normative) +// ============================================================================ + +/// Movement speed in units per second. +/// NORMATIVE: This value MUST be recorded in ReplayArtifact tuning_parameters +/// with key "move_speed" per INV-0006. +pub const MOVE_SPEED: f64 = 5.0; + +// ============================================================================ +// StateDigest Implementation (ADR-0007) +// ============================================================================ + +/// StateDigest algorithm identifier for v0. +/// Ref: ADR-0007 +pub const STATE_DIGEST_ALGO_ID: &str = "statedigest-v0-fnv1a64-le-f64canon-eidasc-posvel"; + +/// FNV-1a 64-bit offset basis. +const FNV1A_OFFSET_BASIS: u64 = 0xcbf29ce484222325; + +/// FNV-1a 64-bit prime. +const FNV1A_PRIME: u64 = 0x100000001b3; + +/// FNV-1a 64-bit hasher for StateDigest computation. +/// Ref: ADR-0007 +#[derive(Debug, Clone)] +struct Fnv1a64 { + state: u64, +} + +impl Fnv1a64 { + fn new() -> Self { + Self { + state: FNV1A_OFFSET_BASIS, + } + } + + fn update(&mut self, bytes: &[u8]) { + for &byte in bytes { + self.state ^= u64::from(byte); + self.state = self.state.wrapping_mul(FNV1A_PRIME); + } + } + + fn finish(self) -> u64 { + self.state + } +} + +/// Canonicalize an f64 value for deterministic hashing. +/// Ref: ADR-0007 +/// +/// Rules: +/// - `-0.0` → `+0.0` +/// - Any NaN → quiet NaN bit pattern `0x7ff8000000000000` +fn canonicalize_f64(value: f64) -> u64 { + const QUIET_NAN_BITS: u64 = 0x7ff8000000000000; + + if value.is_nan() { + QUIET_NAN_BITS + } else if value == 0.0 { + // Both +0.0 and -0.0 compare equal to 0.0 + // Canonicalize to +0.0 bit pattern + 0u64 + } else { + value.to_bits() + } +} + +// ============================================================================ +// Internal Entity Types +// ============================================================================ + +/// Internal representation of a Character entity. +/// Ref: DM-0003, DM-0005 +#[derive(Debug, Clone)] +struct Character { + entity_id: EntityId, + player_id: PlayerId, + position: [f64; 2], + velocity: [f64; 2], +} + +impl Character { + fn new(entity_id: EntityId, player_id: PlayerId) -> Self { + Self { + entity_id, + player_id, + position: [0.0, 0.0], + velocity: [0.0, 0.0], + } + } + + fn to_snapshot(&self) -> EntitySnapshot { + EntitySnapshot { + entity_id: self.entity_id, + position: self.position, + velocity: self.velocity, + } + } +} + +// ============================================================================ +// World Implementation (DM-0002) +// ============================================================================ -/// Simulation stub. -#[derive(Debug, Default)] -pub struct Sim; +/// The authoritative simulation state container. +/// Ref: DM-0002 +/// +/// Contains entities and advances simulation state each Tick. +/// The Simulation Core maintains World state and advances it via `advance()`. +#[derive(Debug, Clone)] +pub struct World { + /// Current simulation tick + tick: Tick, + /// Configured tick rate (Hz) + tick_rate_hz: u32, + /// Computed delta time per tick (seconds) + dt_seconds: f64, + /// Characters indexed by player_id + /// Note: We use a Vec and search by player_id to maintain deterministic ordering + characters: Vec, + /// Next entity ID to assign (deterministic allocation) + next_entity_id: EntityId, + /// RNG seed (recorded for replay, not currently used in v0 movement) + #[allow(dead_code)] + seed: u64, +} + +impl World { + /// Create a new World. + /// Ref: DM-0002 + /// + /// v0 NORMATIVE: World::new() creates World at tick 0. + /// + /// # Arguments + /// * `seed` - RNG seed (recorded for replay) + /// * `tick_rate_hz` - Simulation tick rate in Hz + pub fn new(seed: u64, tick_rate_hz: u32) -> Self { + assert!(tick_rate_hz > 0, "tick_rate_hz must be positive"); + + Self { + tick: 0, + tick_rate_hz, + dt_seconds: 1.0 / f64::from(tick_rate_hz), + characters: Vec::new(), + next_entity_id: 1, // Start at 1 (0 could be reserved) + seed, + } + } + + /// Spawn a character for the given player. + /// Returns the EntityId of the spawned character. + /// Ref: DM-0003, DM-0020 + /// + /// EntityId assignment is deterministic based on spawn order. + pub fn spawn_character(&mut self, player_id: PlayerId) -> EntityId { + let entity_id = self.next_entity_id; + self.next_entity_id += 1; + + let character = Character::new(entity_id, player_id); + self.characters.push(character); + + // Maintain sorted order by entity_id for deterministic iteration (INV-0007) + self.characters.sort_by_key(|c| c.entity_id); + + entity_id + } + + /// Get the current simulation tick. + /// Ref: DM-0001 + pub fn tick(&self) -> Tick { + self.tick + } + + /// Get the configured tick rate in Hz. + pub fn tick_rate_hz(&self) -> u32 { + self.tick_rate_hz + } + + /// Get the pre-step world state (Baseline) at the current tick. + /// Ref: DM-0016 + /// + /// Postcondition: baseline().tick == world.tick() + pub fn baseline(&self) -> Baseline { + let entities = self.sorted_entity_snapshots(); + let digest = self.state_digest(); + + Baseline { + tick: self.tick, + entities, + digest, + } + } + + /// Advance simulation from tick T to T+1. + /// Ref: DM-0007, INV-0002, ADR-0003 + /// + /// # Arguments + /// * `tick` - The pre-step tick (MUST equal self.tick()) + /// * `step_inputs` - Inputs sorted by player_id ascending (INV-0007) + /// + /// # Returns + /// Snapshot with snapshot.tick = tick + 1 (post-step tick) + /// + /// # Panics + /// If `tick != self.tick()` (precondition violation) + pub fn advance(&mut self, tick: Tick, step_inputs: &[StepInput]) -> Snapshot { + // Precondition: tick MUST == self.tick() (ADR-0003) + assert_eq!( + tick, self.tick, + "advance() tick mismatch: expected {}, got {}", + self.tick, tick + ); + + // Debug assert: inputs must be sorted by player_id (INV-0007) + debug_assert!( + step_inputs + .windows(2) + .all(|w| w[0].player_id <= w[1].player_id), + "step_inputs must be sorted by player_id ascending" + ); -impl Sim { - /// Create a new simulation instance. - pub fn new() -> Self { - Self + // Apply movement physics for each input + for input in step_inputs { + self.apply_movement(input); + } + + // Advance tick + self.tick += 1; + + // Build and return snapshot + let entities = self.sorted_entity_snapshots(); + let digest = self.state_digest(); + + Snapshot { + tick: self.tick, + entities, + digest, + } + } + + /// Compute the StateDigest for the current world state. + /// Ref: ADR-0007 + /// + /// Algorithm: FNV-1a 64-bit with canonicalization + /// - `-0.0` → `+0.0` + /// - NaN → quiet NaN `0x7ff8000000000000` + /// - Entities iterated by EntityId ascending + pub fn state_digest(&self) -> u64 { + let mut hasher = Fnv1a64::new(); + + // Hash tick (u64, little-endian) + hasher.update(&self.tick.to_le_bytes()); + + // Hash entities in EntityId ascending order (INV-0007) + // Characters are maintained sorted by entity_id + for character in &self.characters { + // entity_id (u64, little-endian) + hasher.update(&character.entity_id.to_le_bytes()); + + // position[0] (f64, canonicalized, little-endian) + hasher.update(&canonicalize_f64(character.position[0]).to_le_bytes()); + // position[1] (f64, canonicalized, little-endian) + hasher.update(&canonicalize_f64(character.position[1]).to_le_bytes()); + + // velocity[0] (f64, canonicalized, little-endian) + hasher.update(&canonicalize_f64(character.velocity[0]).to_le_bytes()); + // velocity[1] (f64, canonicalized, little-endian) + hasher.update(&canonicalize_f64(character.velocity[1]).to_le_bytes()); + } + + hasher.finish() + } + + // ======================================================================== + // Internal Methods + // ======================================================================== + + /// Apply movement physics for a single input. + /// Ref: v0 Movement Model in spec + fn apply_movement(&mut self, input: &StepInput) { + // Find character by player_id + let Some(character) = self + .characters + .iter_mut() + .find(|c| c.player_id == input.player_id) + else { + // No character for this player_id; skip (defensive) + return; + }; + + // Clamp move_dir magnitude to 1.0 (defense-in-depth; validation is Server Edge) + let move_dir = clamp_magnitude(input.move_dir, 1.0); + + // v0 Movement Model: + // velocity = move_dir * MOVE_SPEED + // position += velocity * dt + character.velocity[0] = move_dir[0] * MOVE_SPEED; + character.velocity[1] = move_dir[1] * MOVE_SPEED; + + character.position[0] += character.velocity[0] * self.dt_seconds; + character.position[1] += character.velocity[1] * self.dt_seconds; + } + + /// Get sorted entity snapshots. + /// Entities are sorted by entity_id ascending (INV-0007). + fn sorted_entity_snapshots(&self) -> Vec { + // Characters are already maintained sorted by entity_id + self.characters.iter().map(Character::to_snapshot).collect() } } +/// Clamp a 2D vector's magnitude to a maximum value. +fn clamp_magnitude(v: [f64; 2], max_magnitude: f64) -> [f64; 2] { + let magnitude_sq = v[0] * v[0] + v[1] * v[1]; + let max_sq = max_magnitude * max_magnitude; + if magnitude_sq <= max_sq { + v + } else { + let magnitude = magnitude_sq.sqrt(); + let scale = max_magnitude / magnitude; + [v[0] * scale, v[1] * scale] + } +} + +// ============================================================================ +// Tests +// ============================================================================ + #[cfg(test)] mod tests { - use super::Sim; + use super::*; + + // ======================================================================== + // Tier 0 Gate: T0.4 — WASD produces deterministic movement + // ======================================================================== + /// T0.4: WASD produces movement with exact f64 equality. + /// Ref: INV-0001, INV-0002 #[test] - fn smoke_test() { - let _sim = Sim::new(); + fn test_t0_04_wasd_deterministic_movement() { + const TICK_RATE_HZ: u32 = 60; + const SEED: u64 = 0; + const NUM_TICKS: u64 = 10; + + let mut world = World::new(SEED, TICK_RATE_HZ); + let player_id: PlayerId = 0; + world.spawn_character(player_id); + + // Move right (x+) for NUM_TICKS ticks + let move_dir = [1.0, 0.0]; + let input = StepInput { + player_id, + move_dir, + }; + + for tick in 0..NUM_TICKS { + let _ = world.advance(tick, std::slice::from_ref(&input)); + } + + // Expected position: + // velocity = move_dir * MOVE_SPEED = [5.0, 0.0] + // position += velocity * dt per tick + // dt = 1/60 + // After 10 ticks: x = 10 * 5.0 * (1/60) = 50/60 = 5/6 + let dt = 1.0 / f64::from(TICK_RATE_HZ); + let expected_x = f64::from(NUM_TICKS as u32) * MOVE_SPEED * dt; + let expected_y = 0.0; + + let snapshot = world.baseline(); + assert_eq!(snapshot.entities.len(), 1); + let entity = &snapshot.entities[0]; + + // Exact f64 equality (no epsilon tolerance - determinism requirement) + assert_eq!( + entity.position[0], expected_x, + "Position X mismatch: got {}, expected {}", + entity.position[0], expected_x + ); + assert_eq!( + entity.position[1], expected_y, + "Position Y mismatch: got {}, expected {}", + entity.position[1], expected_y + ); + } + + /// T0.4: Multiple runs produce identical results (determinism). + #[test] + fn test_t0_04_determinism_multiple_runs() { + const TICK_RATE_HZ: u32 = 60; + const SEED: u64 = 42; + const NUM_TICKS: u64 = 100; + + fn run_simulation() -> (Vec, u64) { + let mut world = World::new(SEED, TICK_RATE_HZ); + world.spawn_character(0); + world.spawn_character(1); + + let inputs = vec![ + StepInput { + player_id: 0, + move_dir: [1.0, 0.0], + }, + StepInput { + player_id: 1, + move_dir: [0.0, 1.0], + }, + ]; + + for tick in 0..NUM_TICKS { + let _ = world.advance(tick, &inputs); + } + + let baseline = world.baseline(); + (baseline.entities, baseline.digest) + } + + // Run twice + let (entities1, digest1) = run_simulation(); + let (entities2, digest2) = run_simulation(); + + // Must be identical + assert_eq!(entities1, entities2, "Entity snapshots differ between runs"); + assert_eq!(digest1, digest2, "State digests differ between runs"); + } + + // ======================================================================== + // Tier 0 Gate: T0.17 — PlayerId Non-assumption + // ======================================================================== + + /// T0.17: Simulation Core works with non-contiguous PlayerIds. + /// Ref: DM-0019 + #[test] + fn test_t0_17_playerid_non_assumption() { + const TICK_RATE_HZ: u32 = 60; + const SEED: u64 = 0; + + let mut world = World::new(SEED, TICK_RATE_HZ); + + // Use non-contiguous, non-zero-based PlayerIds as per spec + let player_a: PlayerId = 17; + let player_b: PlayerId = 99; + + let entity_a = world.spawn_character(player_a); + let entity_b = world.spawn_character(player_b); + + // Verify entities were created + assert!(entity_a > 0); + assert!(entity_b > 0); + assert_ne!(entity_a, entity_b); + + // Inputs must be sorted by player_id + let inputs = vec![ + StepInput { + player_id: player_a, // 17 + move_dir: [1.0, 0.0], + }, + StepInput { + player_id: player_b, // 99 + move_dir: [0.0, 1.0], + }, + ]; + + // Advance simulation + let snapshot = world.advance(0, &inputs); + assert_eq!(snapshot.tick, 1); + assert_eq!(snapshot.entities.len(), 2); + + // Verify both characters moved correctly + let dt = 1.0 / f64::from(TICK_RATE_HZ); + let expected_movement = MOVE_SPEED * dt; + + // Find entity A (player 17 moves right) + let entity_a_snapshot = snapshot + .entities + .iter() + .find(|e| e.entity_id == entity_a) + .unwrap(); + assert_eq!(entity_a_snapshot.position[0], expected_movement); + assert_eq!(entity_a_snapshot.position[1], 0.0); + + // Find entity B (player 99 moves up) + let entity_b_snapshot = snapshot + .entities + .iter() + .find(|e| e.entity_id == entity_b) + .unwrap(); + assert_eq!(entity_b_snapshot.position[0], 0.0); + assert_eq!(entity_b_snapshot.position[1], expected_movement); + } + + // ======================================================================== + // StateDigest Tests (ADR-0007) + // ======================================================================== + + #[test] + fn test_state_digest_deterministic() { + let mut world1 = World::new(0, 60); + let mut world2 = World::new(0, 60); + + world1.spawn_character(0); + world2.spawn_character(0); + + assert_eq!(world1.state_digest(), world2.state_digest()); + + let input = StepInput { + player_id: 0, + move_dir: [1.0, 0.0], + }; + + world1.advance(0, std::slice::from_ref(&input)); + world2.advance(0, std::slice::from_ref(&input)); + + assert_eq!(world1.state_digest(), world2.state_digest()); + } + + #[test] + fn test_state_digest_changes_with_state() { + let mut world = World::new(0, 60); + world.spawn_character(0); + + let digest_before = world.state_digest(); + + let input = StepInput { + player_id: 0, + move_dir: [1.0, 0.0], + }; + world.advance(0, &[input]); + + let digest_after = world.state_digest(); + + assert_ne!( + digest_before, digest_after, + "Digest should change after state change" + ); + } + + #[test] + fn test_f64_canonicalization() { + // Test -0.0 canonicalization + assert_eq!(canonicalize_f64(-0.0), canonicalize_f64(0.0)); + assert_eq!(canonicalize_f64(-0.0), 0u64); + + // Test NaN canonicalization + let nan1 = f64::NAN; + let nan2 = f64::from_bits(0x7ff0000000000001); // Another NaN + assert_eq!(canonicalize_f64(nan1), canonicalize_f64(nan2)); + assert_eq!(canonicalize_f64(nan1), 0x7ff8000000000000); + + // Test normal values are unchanged + assert_eq!(canonicalize_f64(1.0), 1.0f64.to_bits()); + assert_eq!(canonicalize_f64(-1.0), (-1.0f64).to_bits()); + } + + // ======================================================================== + // World API Tests + // ======================================================================== + + #[test] + fn test_world_new_starts_at_tick_zero() { + let world = World::new(0, 60); + assert_eq!(world.tick(), 0, "World should start at tick 0"); + } + + #[test] + fn test_world_tick_rate() { + let world = World::new(0, 60); + assert_eq!(world.tick_rate_hz(), 60); + + let world2 = World::new(0, 30); + assert_eq!(world2.tick_rate_hz(), 30); + } + + #[test] + fn test_spawn_character_returns_unique_ids() { + let mut world = World::new(0, 60); + + let id1 = world.spawn_character(0); + let id2 = world.spawn_character(1); + let id3 = world.spawn_character(2); + + assert_ne!(id1, id2); + assert_ne!(id2, id3); + assert_ne!(id1, id3); + } + + #[test] + fn test_baseline_matches_tick() { + let world = World::new(0, 60); + let baseline = world.baseline(); + assert_eq!(baseline.tick, world.tick()); + } + + #[test] + fn test_advance_increments_tick() { + let mut world = World::new(0, 60); + world.spawn_character(0); + + assert_eq!(world.tick(), 0); + + let snapshot = world.advance(0, &[]); + assert_eq!(world.tick(), 1); + assert_eq!(snapshot.tick, 1); + + let snapshot2 = world.advance(1, &[]); + assert_eq!(world.tick(), 2); + assert_eq!(snapshot2.tick, 2); + } + + #[test] + #[should_panic(expected = "advance() tick mismatch")] + fn test_advance_panics_on_tick_mismatch() { + let mut world = World::new(0, 60); + world.spawn_character(0); + + // Try to advance with wrong tick + world.advance(5, &[]); + } + + #[test] + fn test_entities_sorted_by_entity_id() { + let mut world = World::new(0, 60); + + // Spawn in reverse order of what entity IDs will be + world.spawn_character(99); + world.spawn_character(50); + world.spawn_character(1); + + let baseline = world.baseline(); + + // Entities should be sorted by entity_id, not player_id + for i in 1..baseline.entities.len() { + assert!( + baseline.entities[i - 1].entity_id < baseline.entities[i].entity_id, + "Entities not sorted by entity_id" + ); + } + } + + #[test] + fn test_movement_clamp_magnitude() { + // Test that oversized move_dir is clamped + let v = clamp_magnitude([2.0, 0.0], 1.0); + assert!((v[0] - 1.0).abs() < 1e-10); + assert!((v[1] - 0.0).abs() < 1e-10); + + // Test that normal magnitude is unchanged + let v2 = clamp_magnitude([0.5, 0.5], 1.0); + assert_eq!(v2, [0.5, 0.5]); + + // Test zero vector + let v3 = clamp_magnitude([0.0, 0.0], 1.0); + assert_eq!(v3, [0.0, 0.0]); + } + + // ======================================================================== + // Tier 0 Gate: T0.5 — Simulation Core Isolation + // ======================================================================== + + /// T0.5: Verify advance() takes explicit tick parameter (ADR-0003). + #[test] + fn test_t0_05_advance_takes_explicit_tick() { + let mut world = World::new(0, 60); + world.spawn_character(0); + + // This test verifies the API signature matches the spec + // advance() takes tick as first parameter + let snapshot = world.advance(0, &[]); + assert_eq!(snapshot.tick, 1); + } + + // ======================================================================== + // Tier 0 Gate: T0.12 — LastKnownIntent Determinism + // ======================================================================== + + /// T0.12: Verify simulation is deterministic even with empty inputs. + #[test] + fn test_t0_12_empty_inputs_deterministic() { + fn run_with_gaps() -> u64 { + let mut world = World::new(0, 60); + world.spawn_character(0); + + // Advance with no inputs (simulating LKI scenario) + for tick in 0..10 { + world.advance(tick, &[]); + } + + world.state_digest() + } + + let digest1 = run_with_gaps(); + let digest2 = run_with_gaps(); + + assert_eq!(digest1, digest2); } } diff --git a/crates/wire/Cargo.toml b/crates/wire/Cargo.toml new file mode 100644 index 0000000..58ec744 --- /dev/null +++ b/crates/wire/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "flowstate-wire" +version = "0.0.0" +edition = "2024" +publish = false +description = "Shared wire protocol types for Flowstate (Protobuf via prost)" + +[dependencies] +prost = "0.13" +flowstate-sim = { path = "../sim" } + +[dev-dependencies] + +[lints.rust] +unsafe_code = "deny" diff --git a/crates/wire/src/lib.rs b/crates/wire/src/lib.rs new file mode 100644 index 0000000..bc436b2 --- /dev/null +++ b/crates/wire/src/lib.rs @@ -0,0 +1,506 @@ +//! Flowstate Wire Protocol Types +//! +//! This crate defines the shared Protobuf message types used for communication +//! between Game Client and Server Edge. Both client and server binaries MUST +//! depend on this crate (T0.19: Schema Identity CI Gate). +//! +//! # Message Categories +//! +//! - **Control Channel** (reliable + ordered): Handshake, lifecycle messages +//! - **Realtime Channel** (unreliable + sequenced): Inputs, snapshots +//! +//! # References +//! +//! - ADR-0005: v0 Networking Architecture +//! - ADR-0006: Input Tick Targeting & TargetTickFloor +//! - ADR-0007: StateDigest Algorithm +//! - DM-0006: InputCmd +//! - DM-0007: Snapshot +//! - DM-0016: Baseline +//! - DM-0017: ReplayArtifact + +#![deny(unsafe_code)] + +use prost::Message; + +// ============================================================================ +// Type Aliases (matching simulation crate) +// ============================================================================ + +/// Tick type alias for wire protocol. +pub type Tick = u64; + +/// PlayerId type alias for wire protocol. +pub type PlayerId = u8; + +/// EntityId type alias for wire protocol. +pub type EntityId = u64; + +/// InputSeq type alias for wire protocol. +/// Ref: DM-0026 +pub type InputSeq = u64; + +// ============================================================================ +// Control Channel Messages +// ============================================================================ + +/// Client initiates handshake. +/// Ref: ADR-0005 (Control Channel) +/// +/// v0: No fields required (handshake initiation only). +/// Future versions MAY add fields (e.g., protocol version, client capabilities). +#[derive(Clone, PartialEq, Message)] +pub struct ClientHello { + // Empty for v0 +} + +/// Server welcome response with session info and tick guidance. +/// Ref: ADR-0005, ADR-0006 (Control Channel) +#[derive(Clone, PartialEq, Message)] +pub struct ServerWelcome { + /// Initial TargetTickFloor for client input targeting. + /// Ref: DM-0025 + #[prost(uint64, tag = "1")] + pub target_tick_floor: Tick, + + /// Server tick rate in Hz. + #[prost(uint32, tag = "2")] + pub tick_rate_hz: u32, + + /// Assigned PlayerId for this session. + /// Ref: DM-0019 + #[prost(uint32, tag = "3")] + pub player_id: u32, // Wire as u32 for protobuf compatibility + + /// EntityId of the Character this client controls. + /// Ref: DM-0020 + #[prost(uint64, tag = "4")] + pub controlled_entity_id: EntityId, +} + +/// Initial baseline state sent to client after welcome. +/// Ref: DM-0016 (Control Channel) +#[derive(Clone, PartialEq, Message)] +pub struct JoinBaseline { + /// Baseline tick. + #[prost(uint64, tag = "1")] + pub tick: Tick, + + /// Entity snapshots, ordered by entity_id ascending per INV-0007. + #[prost(message, repeated, tag = "2")] + pub entities: Vec, + + /// StateDigest at this tick (ADR-0007). + #[prost(uint64, tag = "3")] + pub digest: u64, +} + +// ============================================================================ +// Realtime Channel Messages +// ============================================================================ + +/// Client input command targeting a specific tick. +/// Ref: DM-0006, ADR-0006 (Realtime Channel) +/// +/// Note: `player_id` is NOT included - bound by Server Edge from session. +#[derive(Clone, PartialEq, Message)] +pub struct InputCmdProto { + /// Target tick for this input. + /// MUST be >= TargetTickFloor. + #[prost(uint64, tag = "1")] + pub tick: Tick, + + /// Per-session sequence number for deterministic selection. + /// Ref: DM-0026 + #[prost(uint64, tag = "2")] + pub input_seq: InputSeq, + + /// Movement direction [x, y], magnitude <= 1.0. + #[prost(double, repeated, tag = "3")] + pub move_dir: Vec, +} + +/// Server snapshot broadcast. +/// Ref: DM-0007, ADR-0006 (Realtime Channel) +#[derive(Clone, PartialEq, Message)] +pub struct SnapshotProto { + /// Post-step tick. + #[prost(uint64, tag = "1")] + pub tick: Tick, + + /// Entity snapshots, ordered by entity_id ascending per INV-0007. + #[prost(message, repeated, tag = "2")] + pub entities: Vec, + + /// StateDigest at this tick (ADR-0007). + #[prost(uint64, tag = "3")] + pub digest: u64, + + /// TargetTickFloor for client input targeting. + /// Ref: DM-0025, ADR-0006 + #[prost(uint64, tag = "4")] + pub target_tick_floor: Tick, +} + +/// Entity snapshot embedded in JoinBaseline/SnapshotProto. +#[derive(Clone, PartialEq, Message)] +pub struct EntitySnapshotProto { + /// EntityId. + /// Ref: DM-0020 + #[prost(uint64, tag = "1")] + pub entity_id: EntityId, + + /// Position [x, y]. + #[prost(double, repeated, tag = "2")] + pub position: Vec, + + /// Velocity [vx, vy]. + #[prost(double, repeated, tag = "3")] + pub velocity: Vec, +} + +// ============================================================================ +// Time Sync Messages (Tier 1 - Stub for future) +// ============================================================================ + +/// Time synchronization ping from client. +/// Ref: Tier 1 (debug/telemetry only) +#[derive(Clone, PartialEq, Message)] +pub struct TimeSyncPing { + /// Client-side timestamp (opaque to server). + #[prost(uint64, tag = "1")] + pub client_timestamp: u64, +} + +/// Time synchronization pong from server. +/// Ref: Tier 1 (debug/telemetry only) +#[derive(Clone, PartialEq, Message)] +pub struct TimeSyncPong { + /// Server's current tick at time of response. + #[prost(uint64, tag = "1")] + pub server_tick: Tick, + + /// Server-side timestamp. + #[prost(uint64, tag = "2")] + pub server_timestamp: u64, + + /// Echo of client's ping timestamp. + #[prost(uint64, tag = "3")] + pub ping_timestamp_echo: u64, +} + +// ============================================================================ +// Replay Artifact Types +// ============================================================================ + +/// Applied input recorded for replay. +/// Ref: DM-0024 +#[derive(Clone, PartialEq, Message)] +pub struct AppliedInputProto { + /// Tick at which this input was applied. + #[prost(uint64, tag = "1")] + pub tick: Tick, + + /// Player this input is for. + #[prost(uint32, tag = "2")] + pub player_id: u32, + + /// Normalized movement direction. + #[prost(double, repeated, tag = "3")] + pub move_dir: Vec, + + /// True if generated via LastKnownIntent fallback. + /// Ref: DM-0023 + #[prost(bool, tag = "4")] + pub is_fallback: bool, +} + +/// Player to Entity mapping for replay initialization. +#[derive(Clone, PartialEq, Message)] +pub struct PlayerEntityMapping { + #[prost(uint32, tag = "1")] + pub player_id: u32, + + #[prost(uint64, tag = "2")] + pub entity_id: EntityId, +} + +/// Tuning parameter key-value pair. +#[derive(Clone, PartialEq, Message)] +pub struct TuningParameter { + #[prost(string, tag = "1")] + pub key: String, + + #[prost(double, tag = "2")] + pub value: f64, +} + +/// Build fingerprint for replay scope verification. +#[derive(Clone, PartialEq, Message)] +pub struct BuildFingerprint { + /// SHA-256 of server executable bytes. + #[prost(string, tag = "1")] + pub binary_sha256: String, + + /// Target triple (e.g., "x86_64-pc-windows-msvc"). + #[prost(string, tag = "2")] + pub target_triple: String, + + /// Build profile ("release" or "dev"). + #[prost(string, tag = "3")] + pub profile: String, + + /// Git commit hash (metadata/traceability). + #[prost(string, tag = "4")] + pub git_commit: String, +} + +/// Complete replay artifact. +/// Ref: DM-0017, INV-0006 +#[derive(Clone, PartialEq, Message)] +pub struct ReplayArtifact { + /// Schema version (v0 starts at 1). + #[prost(uint32, tag = "1")] + pub replay_format_version: u32, + + /// Initial baseline at match start. + /// Ref: DM-0016 + #[prost(message, optional, tag = "2")] + pub initial_baseline: Option, + + /// RNG seed. + #[prost(uint64, tag = "3")] + pub seed: u64, + + /// RNG algorithm identifier (e.g., "ChaCha8Rng"). + #[prost(string, tag = "4")] + pub rng_algorithm: String, + + /// Simulation tick rate. + #[prost(uint32, tag = "5")] + pub tick_rate_hz: u32, + + /// StateDigest algorithm identifier (ADR-0007). + #[prost(string, tag = "6")] + pub state_digest_algo_id: String, + + /// Entity spawn order (PlayerIds in spawn sequence). + #[prost(uint32, repeated, tag = "7")] + pub entity_spawn_order: Vec, + + /// Player to Entity mapping. + #[prost(message, repeated, tag = "8")] + pub player_entity_mapping: Vec, + + /// Tuning parameters (sorted by key). + #[prost(message, repeated, tag = "9")] + pub tuning_parameters: Vec, + + /// Applied input stream. + /// Ref: DM-0024 + #[prost(message, repeated, tag = "10")] + pub inputs: Vec, + + /// Build fingerprint for verification scope. + #[prost(message, optional, tag = "11")] + pub build_fingerprint: Option, + + /// StateDigest at checkpoint_tick. + #[prost(uint64, tag = "12")] + pub final_digest: u64, + + /// Post-step tick for verification anchor. + #[prost(uint64, tag = "13")] + pub checkpoint_tick: Tick, + + /// Match termination reason. + #[prost(string, tag = "14")] + pub end_reason: String, + + /// Test mode flag. + #[prost(bool, tag = "15")] + pub test_mode: bool, + + /// Test player IDs (when test_mode=true). + #[prost(uint32, repeated, tag = "16")] + pub test_player_ids: Vec, +} + +// ============================================================================ +// Conversion Traits +// ============================================================================ + +impl From for EntitySnapshotProto { + fn from(e: flowstate_sim::EntitySnapshot) -> Self { + Self { + entity_id: e.entity_id, + position: e.position.to_vec(), + velocity: e.velocity.to_vec(), + } + } +} + +impl TryFrom for flowstate_sim::EntitySnapshot { + type Error = &'static str; + + fn try_from(e: EntitySnapshotProto) -> Result { + if e.position.len() != 2 { + return Err("position must have exactly 2 elements"); + } + if e.velocity.len() != 2 { + return Err("velocity must have exactly 2 elements"); + } + Ok(Self { + entity_id: e.entity_id, + position: [e.position[0], e.position[1]], + velocity: [e.velocity[0], e.velocity[1]], + }) + } +} + +impl From for JoinBaseline { + fn from(b: flowstate_sim::Baseline) -> Self { + Self { + tick: b.tick, + entities: b.entities.into_iter().map(Into::into).collect(), + digest: b.digest, + } + } +} + +impl TryFrom for flowstate_sim::Baseline { + type Error = &'static str; + + fn try_from(b: JoinBaseline) -> Result { + let entities: Result, _> = b.entities.into_iter().map(TryInto::try_into).collect(); + Ok(Self { + tick: b.tick, + entities: entities?, + digest: b.digest, + }) + } +} + +impl From for SnapshotProto { + fn from(s: flowstate_sim::Snapshot) -> Self { + Self { + tick: s.tick, + entities: s.entities.into_iter().map(Into::into).collect(), + digest: s.digest, + target_tick_floor: 0, // Must be set by caller + } + } +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_client_hello_roundtrip() { + let msg = ClientHello {}; + let encoded = msg.encode_to_vec(); + let decoded = ClientHello::decode(encoded.as_slice()).unwrap(); + assert_eq!(msg, decoded); + } + + #[test] + fn test_server_welcome_roundtrip() { + let msg = ServerWelcome { + target_tick_floor: 5, + tick_rate_hz: 60, + player_id: 1, + controlled_entity_id: 42, + }; + let encoded = msg.encode_to_vec(); + let decoded = ServerWelcome::decode(encoded.as_slice()).unwrap(); + assert_eq!(msg, decoded); + } + + #[test] + fn test_input_cmd_roundtrip() { + let msg = InputCmdProto { + tick: 100, + input_seq: 50, + move_dir: vec![0.707, 0.707], + }; + let encoded = msg.encode_to_vec(); + let decoded = InputCmdProto::decode(encoded.as_slice()).unwrap(); + assert_eq!(msg, decoded); + } + + #[test] + fn test_snapshot_roundtrip() { + let msg = SnapshotProto { + tick: 100, + entities: vec![EntitySnapshotProto { + entity_id: 1, + position: vec![10.5, 20.5], + velocity: vec![1.0, 0.0], + }], + digest: 0xdeadbeef, + target_tick_floor: 101, + }; + let encoded = msg.encode_to_vec(); + let decoded = SnapshotProto::decode(encoded.as_slice()).unwrap(); + assert_eq!(msg, decoded); + } + + #[test] + fn test_replay_artifact_roundtrip() { + let msg = ReplayArtifact { + replay_format_version: 1, + initial_baseline: Some(JoinBaseline { + tick: 0, + entities: vec![], + digest: 0, + }), + seed: 42, + rng_algorithm: "ChaCha8Rng".to_string(), + tick_rate_hz: 60, + state_digest_algo_id: "statedigest-v0-fnv1a64-le-f64canon-eidasc-posvel".to_string(), + entity_spawn_order: vec![0, 1], + player_entity_mapping: vec![ + PlayerEntityMapping { + player_id: 0, + entity_id: 1, + }, + PlayerEntityMapping { + player_id: 1, + entity_id: 2, + }, + ], + tuning_parameters: vec![TuningParameter { + key: "move_speed".to_string(), + value: 5.0, + }], + inputs: vec![], + build_fingerprint: Some(BuildFingerprint { + binary_sha256: "abc123".to_string(), + target_triple: "x86_64-pc-windows-msvc".to_string(), + profile: "release".to_string(), + git_commit: "deadbeef".to_string(), + }), + final_digest: 0xfeedface, + checkpoint_tick: 3600, + end_reason: "complete".to_string(), + test_mode: false, + test_player_ids: vec![], + }; + let encoded = msg.encode_to_vec(); + let decoded = ReplayArtifact::decode(encoded.as_slice()).unwrap(); + assert_eq!(msg, decoded); + } + + /// T0.19: Verify this crate exists and can be depended upon. + #[test] + fn test_t0_19_wire_crate_exists() { + // This test's existence in the shared crate proves the crate exists. + // CI will verify both server and client depend on this crate. + // The test body is empty - the existence of this test is the assertion. + } +} diff --git a/docs/impl/FS-0007-gates.md b/docs/impl/FS-0007-gates.md new file mode 100644 index 0000000..207595c --- /dev/null +++ b/docs/impl/FS-0007-gates.md @@ -0,0 +1,1079 @@ +# FS-0007: v0 Two-Client Multiplayer Slice — Test Gate Specifications + +> **Spec:** [FS-0007-v0-multiplayer-slice.md](../specs/FS-0007-v0-multiplayer-slice.md) +> **Plan:** [FS-0007-plan.md](FS-0007-plan.md) +> **Issue:** [#7](https://github.com/project-flowstate/flowstate/issues/7) +> **Date:** 2025-12-23 + +--- + +## Overview + +This document converts each Tier-0 gate from FS-0007 into executable test specifications. Each gate includes test name, location, setup, assertions, constitution IDs enforced, and required fixtures. + +### v0 Parameters Reference + +From [v0-parameters.md](../networking/v0-parameters.md): + +| Parameter | Value | +|-----------|-------| +| `tick_rate_hz` | 60 | +| `max_future_ticks` | 120 | +| `input_lead_ticks` | 1 | +| `input_rate_limit_per_sec` | 120 | +| `match_duration_ticks` | 3600 | +| `connect_timeout_ms` | 30000 | +| `MOVE_SPEED` | 5.0 | +| `per_tick_limit` | ceil(120/60) = 2 | + +--- + +## T0.1: Two Clients Connect, Complete Handshake + +### Test Name +`test_t0_01_two_client_handshake` + +### Test Location +`crates/server/tests/integration/t0_01_handshake.rs` + +### Setup +1. Start server in manual-step mode with fixed seed (0) +2. Create two mock ENet clients (Client A, Client B) +3. Configure clients to connect to server within `connect_timeout_ms` + +### Assertions +1. **Both clients connect successfully** — server accepts exactly two peer connections +2. **ServerWelcome received by each client** with all required fields: + - `target_tick_floor == 0 + input_lead_ticks == 1` (initial tick 0 + lead 1) + - `tick_rate_hz == 60` + - `player_id` — first client receives 0, second receives 1 (connection order) + - `controlled_entity_id` — valid EntityId corresponding to spawned Character +3. **JoinBaseline received by each client** with: + - `tick == 0` (initial tick per World::new()) + - `entities.len() == 2` (both Characters spawned) + - `entities` sorted by `entity_id` ascending + - `digest` — valid StateDigest matching World::state_digest() +4. **ServerWelcome.controlled_entity_id matches** the entity with position corresponding to that player's spawn + +### Constitution IDs Enforced +- **DM-0008** (Session): Client connection lifecycle +- **DM-0019** (PlayerId): Per-match participant identifier assigned at handshake +- **DM-0025** (TargetTickFloor): Initial floor value emitted in ServerWelcome +- **DM-0016** (Baseline): Initial state serialization + +### Fixtures Needed +- None (deterministic setup with seed=0) + +--- + +## T0.2: JoinBaseline Delivers Initial Baseline + +### Test Name +`test_t0_02_join_baseline_delivery` + +### Test Location +`crates/server/tests/integration/t0_02_baseline.rs` + +### Setup +1. Start server in manual-step mode with fixed seed (0) +2. Connect two mock clients, complete handshake +3. Parse received JoinBaseline messages + +### Assertions +1. **Both clients receive JoinBaseline** on Control channel (reliable + ordered) +2. **Baseline.tick == 0** (World::new() postcondition) +3. **Baseline.entities contains exactly 2 EntitySnapshot entries**: + - Each with valid `entity_id` (non-zero, unique) + - Each with `position == [0.0, 0.0]` (initial spawn position) + - Each with `velocity == [0.0, 0.0]` (initial velocity) +4. **entities sorted by entity_id ascending** (INV-0007) +5. **Baseline.digest matches recomputed digest**: + - Create local World with same seed/tick_rate_hz + - Spawn characters in same order + - Assert `world.baseline().digest == received_baseline.digest` +6. **Client can map controlled_entity_id to an entity in Baseline** + +### Constitution IDs Enforced +- **DM-0016** (Baseline): Pre-step state at tick T +- **DM-0007** (Snapshot): Snapshot structure (shared with Baseline) +- **INV-0007** (Deterministic Ordering): Entity ordering by EntityId ascending + +### Fixtures Needed +- None (deterministic seed=0 produces predictable entity state) + +--- + +## T0.3: Clients Tag Inputs Per ADR-0006 + +### Test Name +`test_t0_03_input_tick_targeting` + +### Test Location +`crates/client/tests/integration/t0_03_input_targeting.rs` +`crates/server/tests/integration/t0_03_input_validation.rs` + +### Setup +1. Start server in manual-step mode +2. Connect two clients, complete handshake (receive ServerWelcome with `target_tick_floor=1`) +3. Client generates InputCmdProto messages for movement + +### Assertions +**Client-side:** +1. **InputCmd.tick >= TargetTickFloor** — client must target tick 1 or higher after receiving `target_tick_floor=1` +2. **InputSeq is strictly monotonic increasing** per session: + - First input: `input_seq=1` + - Second input: `input_seq=2` + - etc. +3. **Client updates target_tick_floor on SnapshotProto receipt** using `max(local_floor, received_floor)` + +**Server-side:** +1. **Inputs with cmd.tick < target_tick_floor are dropped** (floor enforcement) +2. **Inputs with valid tick are buffered** for processing +3. **InputSeq is logged if non-increasing** (protocol violation, but not dropped solely for this) + +### Constitution IDs Enforced +- **ADR-0006** (Input Tick Targeting): Server-guided targeting, InputSeq semantics +- **DM-0025** (TargetTickFloor): Client clamping to floor +- **DM-0026** (InputSeq): Per-session monotonic sequence number + +### Fixtures Needed +- None + +--- + +## T0.4: WASD Produces Movement + +### Test Name +`test_t0_04_deterministic_movement` + +### Test Location +`crates/sim/tests/unit/t0_04_movement.rs` + +### Setup +1. Create World with seed=0, tick_rate_hz=60 +2. Spawn one Character (player_id=0) → returns entity_id +3. Define `N = 60` consecutive ticks (1 second of movement) +4. Define `move_dir = [1.0, 0.0]` (full right) + +### Assertions +1. **Initial position is [0.0, 0.0]** +2. **After N ticks with move_dir=[1.0, 0.0]:** + - `expected_delta_x = MOVE_SPEED * dt * N = 5.0 * (1.0/60.0) * 60 = 5.0` + - `final_position.x == 5.0` (exact f64 equality, no epsilon) + - `final_position.y == 0.0` (exact f64 equality) +3. **Velocity per tick:** + - `velocity = move_dir * MOVE_SPEED = [5.0, 0.0]` +4. **Determinism across runs:** + - Run the same sequence twice with same seed + - Assert identical position, velocity, and state_digest after each tick +5. **No fast-math contamination:** + - Verify Cargo.toml for sim crate does NOT enable fast-math or unsafe float opts + +### Constitution IDs Enforced +- **INV-0001** (Deterministic Simulation): Identical outcomes from identical inputs +- **INV-0002** (Fixed Timestep): dt = 1.0/tick_rate_hz, no frame-rate dependence + +### Fixtures Needed +- **Golden file:** `fixtures/movement/t0_04_60ticks_right.json` + ```json + { + "seed": 0, + "tick_rate_hz": 60, + "ticks": 60, + "move_dir": [1.0, 0.0], + "expected_final_position": [5.0, 0.0], + "expected_final_velocity": [5.0, 0.0], + "expected_final_digest": "" + } + ``` + +--- + +## T0.5: Simulation Core Isolation Enforced + +### Test Name +`test_t0_05_simulation_core_isolation` + +### Test Location +`crates/sim/tests/isolation/t0_05_isolation.rs` +CI script: `scripts/check_sim_isolation.py` or inline `just` target + +### Setup +1. Parse `crates/sim/Cargo.toml` for dependencies +2. Scan `crates/sim/src/**/*.rs` for forbidden API patterns + +### Assertions +**Crate Separation:** +1. **sim crate exists** at `crates/sim/` +2. **sim crate has `#![deny(unsafe_code)]`** at crate root + +**Dependency Allowlist (CI):** +3. **Only allowlisted dependencies** permitted for sim crate: + - `std` (subset: no `std::fs`, `std::net`, `std::time`, `std::env`, `std::thread::sleep`) + - (future: explicit allowlist in `scripts/allowed_sim_deps.txt`) +4. **No I/O crates:** `tokio`, `async-std`, `reqwest`, `hyper`, `mio`, etc. + +**Forbidden-API Scan (CI):** +5. **No file I/O:** patterns `std::fs::`, `File::`, `OpenOptions` +6. **No network I/O:** patterns `std::net::`, `TcpStream`, `UdpSocket` +7. **No wall-clock time:** patterns `std::time::Instant`, `std::time::SystemTime`, `Instant::now()`, `SystemTime::now()` +8. **No thread sleep:** patterns `std::thread::sleep`, `thread::sleep` +9. **No environment access:** patterns `std::env::var`, `env::vars` +10. **No unseeded RNG:** patterns `rand::thread_rng`, `OsRng`, `getrandom` + +**API Contract:** +11. **advance() takes explicit tick parameter:** + ```rust + fn advance(&mut self, tick: Tick, step_inputs: &[StepInput]) -> Snapshot + ``` + - Parse function signature in `crates/sim/src/lib.rs` + - Assert `tick` parameter exists (per ADR-0003) + +### Constitution IDs Enforced +- **INV-0004** (Simulation Core Isolation): No I/O, networking, wall-clock, ambient RNG +- **KC-0001** (Kill: Boundary Violation): Boundary violation = project kill + +### Fixtures Needed +- **Allowlist file:** `scripts/allowed_sim_deps.txt` +- **Forbidden patterns:** `scripts/forbidden_sim_patterns.txt` + +--- + +## T0.5a: Tick/Floor Relationship Assertion + +### Test Name +`test_t0_05a_tick_floor_relationship` + +### Test Location +`crates/server/tests/integration/t0_05a_tick_floor.rs` + +### Setup +1. Create World with seed=0, tick_rate_hz=60 +2. Spawn characters +3. Call `world.advance(T, inputs)` for T = 0, 1, 2, ... + +### Assertions +1. **Pre-advance:** `world.tick() == T` +2. **Post-advance:** `world.tick() == T + 1` +3. **Snapshot.tick == T + 1** (post-step tick) +4. **TargetTickFloor computation:** + - `target_tick_floor = snapshot.tick + input_lead_ticks` + - For `input_lead_ticks = 1`: `target_tick_floor = (T + 1) + 1 = T + 2` +5. **SnapshotProto.target_tick_floor matches** computed value +6. **Advance precondition enforced:** + - Calling `world.advance(T+5, inputs)` when `world.tick() == T` must panic/error + +### Constitution IDs Enforced +- **ADR-0006** (Input Tick Targeting): TargetTickFloor = server.current_tick + input_lead_ticks +- **ADR-0003** (Fixed Timestep): Explicit tick parameter contract + +### Fixtures Needed +- None + +--- + +## T0.6: Validation Per v0-parameters.md + +### Test Name +`test_t0_06_input_validation_matrix` + +### Test Location +`crates/server/tests/integration/t0_06_validation.rs` + +### Setup +1. Start server in manual-step mode +2. Connect two clients, complete handshake +3. Send various InputCmdProto messages with invalid values + +### Assertions + +**Magnitude Clamping:** +1. `move_dir = [2.0, 0.0]` → clamped to `[1.0, 0.0]` + logged +2. `move_dir = [0.6, 0.8]` (magnitude=1.0) → accepted as-is +3. `move_dir = [0.7, 0.8]` (magnitude>1.0) → clamped to unit length + logged + +**NaN/Inf Drop:** +4. `move_dir = [NaN, 0.0]` → dropped + logged +5. `move_dir = [Inf, 0.0]` → dropped + logged +6. `move_dir = [-Inf, 0.0]` → dropped + logged +7. `move_dir = [0.0, NaN]` → dropped + logged + +**Tick Window:** +8. `cmd.tick < current_tick` (late) → dropped +9. `cmd.tick > current_tick + max_future_ticks` (too far future) → dropped +10. `cmd.tick == current_tick + max_future_ticks` → accepted (boundary) + +**Rate Limit:** +11. Send 3 inputs for same (session, tick): 3rd dropped (per_tick_limit=2) +12. Send 2 inputs for same tick: both considered for InputSeq selection + +**InputSeq Selection (DM-0026):** +13. Inputs with seq {5, 7, 6} for same (session, tick): seq=7 wins +14. Inputs with seq {8, 8} for same (session, tick): tie → LKI fallback + logged + +**LastKnownIntent (DM-0023):** +15. No input for (player_id, tick): LKI used (initially [0,0]) +16. After valid input with `move_dir=[1,0]`: subsequent LKI is [1,0] + +**Player Identity Binding (INV-0003):** +17. InputCmdProto has no player_id field → server binds from session +18. Server ignores any client-provided player_id (if somehow present) + +### Constitution IDs Enforced +- **INV-0003** (Authoritative Simulation): player_id bound by Server Edge +- **DM-0022** (InputTickWindow): Tick window validation +- **DM-0023** (LastKnownIntent): Input continuity fallback +- **DM-0026** (InputSeq): Deterministic selection + +### Fixtures Needed +- None (programmatic invalid input generation) + +--- + +## T0.7: Malformed Inputs Do Not Crash Server + +### Test Name +`test_t0_07_malformed_input_robustness` + +### Test Location +`crates/server/tests/integration/t0_07_robustness.rs` + +### Setup +1. Start server in manual-step mode +2. Connect two clients +3. Send a variety of malformed/adversarial InputCmdProto messages + +### Assertions +**Server does not crash/panic for any of:** +1. `move_dir = [NaN, NaN]` +2. `move_dir = [Inf, -Inf]` +3. `move_dir = [1e308, 1e308]` (huge magnitude) +4. `move_dir = [-1e308, -1e308]` +5. `tick = 0` (below floor, if floor > 0) +6. `tick = u64::MAX` +7. `input_seq = 0` +8. `input_seq = u64::MAX` +9. Truncated protobuf message (incomplete bytes) +10. Empty protobuf message +11. Protobuf with unknown fields +12. Rapid-fire messages (100+ in one network tick) + +**Server continues operating:** +13. After all malformed inputs, server still processes valid inputs +14. Match can still complete normally +15. Replay artifact is still generated + +### Constitution IDs Enforced +- **KC-0001** (Kill: Boundary Violation): Must not violate Simulation Core boundary under any input + +### Fixtures Needed +- **Fuzz corpus (optional):** `fixtures/fuzz/malformed_inputs/` + +--- + +## T0.8: Replay Artifact Generated with All Required Fields + +### Test Name +`test_t0_08_replay_artifact_fields` + +### Test Location +`crates/server/tests/integration/t0_08_replay_artifact.rs` + +### Setup +1. Run complete match (match_duration_ticks=3600 or shortened for test) +2. Or trigger disconnect to end match early +3. Read generated replay artifact from `replays/{match_id}.replay` + +### Assertions +**All required fields present and valid:** + +| Field | Assertion | +|-------|-----------| +| `replay_format_version` | `>= 1` (starts at 1) | +| `initial_baseline.tick` | `== 0` | +| `initial_baseline.entities` | `len() == 2`, sorted by entity_id | +| `initial_baseline.digest` | Non-zero, matches recomputed | +| `seed` | `== 0` (default seed) | +| `rng_algorithm` | Non-empty string (e.g., "ChaCha8Rng") | +| `tick_rate_hz` | `== 60` | +| `state_digest_algo_id` | `== "statedigest-v0-fnv1a64-le-f64canon-eidasc-posvel"` | +| `entity_spawn_order` | `== [0, 1]` or test-mode override | +| `player_entity_mapping` | 2 entries, sorted by player_id | +| `tuning_parameters` | Contains `{key: "move_speed", value: "5.0"}` | +| `inputs` | Non-empty, sorted by (tick, player_id) | +| `build_fingerprint.binary_sha256` | 64 hex chars or "unknown" (dev) | +| `build_fingerprint.target_triple` | Non-empty (e.g., "x86_64-pc-windows-msvc") | +| `build_fingerprint.profile` | "release" or "dev" | +| `build_fingerprint.git_commit` | Non-empty string | +| `final_digest` | Non-zero | +| `checkpoint_tick` | `== initial_baseline.tick + match_duration_ticks` or disconnect tick | +| `end_reason` | "complete" or "disconnect" | + +**AppliedInput stream integrity:** +1. For each player_id in player_entity_mapping +2. For each tick in [initial_baseline.tick, checkpoint_tick) +3. Exactly one AppliedInput entry exists + +### Constitution IDs Enforced +- **DM-0017** (ReplayArtifact): Versioned record for replay verification +- **INV-0006** (Replay Verifiability): All required fields for reproduction + +### Fixtures Needed +- **Expected artifact schema:** `fixtures/replay/expected_artifact_schema.json` + +--- + +## T0.9: Replay Verification Passes + +### Test Name +`test_t0_09_replay_verification` + +### Test Location +`crates/replay/tests/integration/t0_09_verification.rs` + +### Setup +1. Generate replay artifact from completed match +2. Load artifact into ReplayVerifier +3. Execute verification procedure + +### Assertions +**Verification procedure:** +1. **Build fingerprint check:** SHA-256 + target_triple + profile match current binary +2. **AppliedInput stream integrity:** No gaps, no duplicates per (player_id, tick) +3. **World initialization:** `World::new(artifact.seed, artifact.tick_rate_hz)` +4. **Spawn reconstruction:** For each player_id in `entity_spawn_order`: + - `entity_id = world.spawn_character(player_id)` + - Assert `entity_id == player_entity_mapping[player_id].entity_id` +5. **Initialization anchor:** `world.baseline().digest == artifact.initial_baseline.digest` +6. **Replay loop:** For each tick T in [initial_baseline.tick, checkpoint_tick): + - Extract AppliedInputs where tick == T + - Sort by player_id ascending + - Convert to StepInput + - `world.advance(T, step_inputs)` +7. **Final tick check:** `world.tick() == artifact.checkpoint_tick` +8. **Final digest check:** `world.state_digest() == artifact.final_digest` + +### Constitution IDs Enforced +- **INV-0006** (Replay Verifiability): Replay reproduction from artifact +- **ADR-0007** (StateDigest): FNV-1a 64-bit, canonicalization + +### Fixtures Needed +- **Golden replay artifact:** `fixtures/replay/t0_09_golden.replay` + - Known match with deterministic seed=0 + - Pre-verified final_digest + +--- + +## T0.10: Initialization Anchor Failure + +### Test Name +`test_t0_10_initialization_anchor_failure` + +### Test Location +`crates/replay/tests/integration/t0_10_anchor_failure.rs` + +### Setup +1. Generate valid replay artifact +2. Mutate `initial_baseline.digest` to incorrect value (e.g., XOR with 0x1) +3. Run ReplayVerifier + +### Assertions +1. **Verification fails immediately** after spawn reconstruction +2. **Failure occurs BEFORE any advance() calls** +3. **Error message indicates** "initialization anchor mismatch" or equivalent +4. **No partial replay** — verifier does not continue past anchor check +5. **Mutating other baseline fields** (entities, tick) also triggers anchor failure + +### Constitution IDs Enforced +- **INV-0006** (Replay Verifiability): Initialization anchor verification requirement + +### Fixtures Needed +- **Mutated artifact:** Programmatically generated from valid artifact + +--- + +## T0.11: Future Input Non-Interference + +### Test Name +`test_t0_11_future_input_buffering` + +### Test Location +`crates/server/tests/integration/t0_11_future_input.rs` + +### Setup +1. Start server, world at tick T +2. Client sends input for tick T+k where k > max_future_ticks +3. Client sends input for tick T+1 (within window) + +### Assertions +**Far-future rejection:** +1. Input for T + (max_future_ticks + 1) = T + 121 is **dropped** + logged +2. Input for T + max_future_ticks = T + 120 is **accepted** (boundary) + +**Near-future buffering:** +3. Input for T+1 is **buffered without affecting T** +4. When processing tick T: + - Only inputs where cmd.tick == T are considered + - T+1 input remains in buffer untouched +5. When processing tick T+1: + - Buffered T+1 input is used + - Position reflects intended movement + +**Buffer independence:** +6. Sending many future inputs does not alter current tick outcome +7. State digest at tick T is identical regardless of buffered future inputs + +### Constitution IDs Enforced +- **DM-0022** (InputTickWindow): Future-only acceptance window + +### Fixtures Needed +- None + +--- + +## T0.12: LastKnownIntent Determinism + +### Test Name +`test_t0_12_last_known_intent_determinism` + +### Test Location +`crates/server/tests/integration/t0_12_lki_determinism.rs` + +### Setup +1. Start server, connect clients +2. Client 0: sends inputs for ticks 0, 1, 2, then stops (gap at ticks 3-5) +3. Client 1: sends inputs for all ticks +4. Complete match +5. Run replay verification + +### Assertions +**Gap filling:** +1. For ticks 3-5, player 0 uses LKI (last move_dir from tick 2) +2. AppliedInput entries for player 0, ticks 3-5 have `is_fallback=true` +3. AppliedInput entries for player 0, ticks 0-2 have `is_fallback=false` + +**Recording in artifact:** +4. All LKI-filled entries are present in `artifact.inputs` +5. `is_fallback` flag correctly set for each entry + +**Replay produces same digest:** +6. Replay verification passes +7. `computed_final_digest == artifact.final_digest` + +**LKI initial value:** +8. Before any valid input, LKI is [0, 0] (neutral) +9. After input with move_dir=[1,0], LKI becomes [1,0] + +### Constitution IDs Enforced +- **DM-0023** (LastKnownIntent): Input continuity fallback +- **INV-0001** (Deterministic Simulation): Determinism with gaps + +### Fixtures Needed +- **Expected LKI sequence:** `fixtures/lki/t0_12_expected.json` + +--- + +## T0.12a: Non-Canonical AppliedInput Storage Order Test + +### Test Name +`test_t0_12a_noncanonical_input_order_robustness` + +### Test Location +`crates/replay/tests/integration/t0_12a_order_robustness.rs` + +### Setup +1. Generate valid replay artifact +2. **Fault injection:** Shuffle `inputs` array to violate canonical order + - Mix tick ordering (non-ascending) + - Mix player_id ordering within same tick +3. Run ReplayVerifier + +### Assertions +1. **Verifier canonicalizes successfully:** + - Extracts inputs by tick + - Sorts by player_id ascending + - Produces correct StepInput sequence +2. **Verification passes** (same final digest) +3. **Dev warning emitted** (optional): "Non-canonical AppliedInput order detected" +4. **No panic or error** from order violation alone + +### Constitution IDs Enforced +- **INV-0007** (Deterministic Ordering): Verifier must canonicalize + +### Fixtures Needed +- **Shuffled artifact:** Programmatically shuffled from valid artifact + +--- + +## T0.13: Validation Matrix + +### Test Name +`test_t0_13_validation_matrix_comprehensive` + +### Test Location +`crates/server/tests/integration/t0_13_validation_matrix.rs` + +### Setup +1. Start server in manual-step mode +2. Connect clients +3. Systematically test each validation rule + +### Assertions + +**NaN detection:** +| Input | Expected | +|-------|----------| +| `[NaN, 0.0]` | DROP | +| `[0.0, NaN]` | DROP | +| `[NaN, NaN]` | DROP | +| `[f64::NAN, 1.0]` | DROP | + +**Magnitude clamping:** +| Input | Expected | +|-------|----------| +| `[2.0, 0.0]` | CLAMP to `[1.0, 0.0]` | +| `[0.0, 2.0]` | CLAMP to `[0.0, 1.0]` | +| `[0.7071, 0.7071]` | ACCEPT (≈1.0) | +| `[0.8, 0.8]` | CLAMP (magnitude ≈1.13) | + +**Tick window:** +| Scenario | Expected | +|----------|----------| +| `cmd.tick == current_tick - 1` | DROP (late) | +| `cmd.tick == current_tick` | ACCEPT | +| `cmd.tick == current_tick + 120` | ACCEPT (boundary) | +| `cmd.tick == current_tick + 121` | DROP (too far) | + +**Rate limit:** +| Scenario | Expected | +|----------|----------| +| 2 inputs for same tick | Both considered for InputSeq | +| 3 inputs for same tick | 3rd dropped | +| N > 2 inputs | At least N-2 dropped | + +**InputSeq selection:** +| Scenario | Expected | +|----------|----------| +| seq {1, 2, 3} | Select seq=3 | +| seq {5, 5} | Tie → LKI fallback | +| seq {3, 5, 4} | Select seq=5 | + +**TargetTickFloor enforcement:** +| Scenario | Expected | +|----------|----------| +| `cmd.tick < last_emitted_floor` | DROP | +| `cmd.tick == last_emitted_floor` | ACCEPT | + +**Pre-Welcome input drop:** +| Scenario | Expected | +|----------|----------| +| Input received before ServerWelcome sent | DROP (no buffer, no log) | + +### Constitution IDs Enforced +- **INV-0003** (Authoritative Simulation) +- **DM-0022** (InputTickWindow) +- **DM-0025** (TargetTickFloor) +- **DM-0026** (InputSeq) + +### Fixtures Needed +- None (programmatic test matrix) + +--- + +## T0.13a: Floor Enforcement Drop and Recovery Test + +### Test Name +`test_t0_13a_floor_enforcement_recovery` + +### Test Location +`crates/server/tests/integration/t0_13a_floor_recovery.rs` + +### Setup +1. Start server, connect clients +2. Process N ticks normally +3. **Simulate snapshot packet loss:** Client does not receive SnapshotProto for ticks T through T+M +4. Client continues sending inputs targeting stale floor + +### Assertions +**Drop behavior:** +1. Inputs targeting tick < `last_emitted_target_tick_floor` are dropped +2. Server logs floor enforcement drops +3. Client movement stalls (LKI fallback on server side) + +**Recovery behavior:** +4. Deliver SnapshotProto containing new floor (tick T+M+1) +5. Client updates local floor to new value +6. Client sends inputs targeting >= new floor +7. Movement resumes within bounded ticks (≤ 2-3 ticks after floor update received) + +**System stability:** +8. No crash or hang during floor staleness period +9. Match continues to completion +10. Replay verification still passes + +### Constitution IDs Enforced +- **ADR-0006** (Input Tick Targeting): Floor enforcement and recovery +- **DM-0025** (TargetTickFloor): Monotonic floor updates + +### Fixtures Needed +- None (network simulation in test) + +--- + +## T0.14: Disconnect Handling + +### Test Name +`test_t0_14_disconnect_handling` + +### Test Location +`crates/server/tests/integration/t0_14_disconnect.rs` + +### Setup +1. Start server, connect two clients +2. Process several ticks +3. Disconnect one client mid-match + +### Assertions +**Tick completion:** +1. Current tick completes before shutdown begins +2. No mid-tick termination (world.tick() is post-step) + +**Artifact persistence:** +3. ReplayArtifact is written to disk +4. `artifact.end_reason == "disconnect"` +5. `artifact.checkpoint_tick == world.tick()` at disconnect detection +6. All AppliedInputs up to checkpoint_tick are recorded + +**Clean shutdown:** +7. Server exits cleanly (exit code 0 or defined non-zero for disconnect) +8. No panic, no crash +9. Remaining connected client receives final snapshot (best-effort) + +### Constitution IDs Enforced +- **DM-0017** (ReplayArtifact): Artifact with end_reason="disconnect" + +### Fixtures Needed +- None + +--- + +## T0.15: Match Termination + +### Test Name +`test_t0_15_match_complete_termination` + +### Test Location +`crates/server/tests/integration/t0_15_match_complete.rs` + +### Setup +1. Start server with `match_duration_ticks=100` (shortened for test) +2. Connect two clients +3. Let match run to completion + +### Assertions +**Duration enforcement:** +1. Server processes exactly ticks [0, match_duration_ticks) = [0, 100) +2. Final `world.tick() == 100` (post-last-step) +3. Server does not process tick 100 (match ends at checkpoint) + +**Artifact content:** +4. `artifact.end_reason == "complete"` +5. `artifact.checkpoint_tick == initial_tick + match_duration_ticks == 100` +6. `artifact.final_digest == world.state_digest()` at tick 100 + +**Clean shutdown:** +7. Server exits with exit code 0 +8. Final snapshot broadcast before exit + +### Constitution IDs Enforced +- **DM-0010** (Match): Match lifecycle with defined end +- **DM-0017** (ReplayArtifact): Artifact with end_reason="complete" + +### Fixtures Needed +- None + +--- + +## T0.16: Connection Timeout + +### Test Name +`test_t0_16_connection_timeout` + +### Test Location +`crates/server/tests/integration/t0_16_connection_timeout.rs` + +### Setup +1. Start server with `connect_timeout_ms=1000` (shortened for test) +2. Connect only one client (or zero clients) +3. Wait for timeout to expire + +### Assertions +**Timeout behavior:** +1. Server waits up to connect_timeout_ms +2. If < 2 sessions connect, server aborts + +**Exit behavior:** +3. Server exits with **non-zero exit code** +4. Specific exit code is documented/consistent + +**No artifact:** +5. **No ReplayArtifact is written** (pre-match timeout) +6. Replay directory does not contain new files + +**Log verification:** +7. Log contains timeout event token (e.g., "CONNECT_TIMEOUT" or similar) +8. CI test asserts on both exit code AND log token + +### Constitution IDs Enforced +- **docs/networking/v0-parameters.md**: connect_timeout_ms parameter + +### Fixtures Needed +- None + +--- + +## T0.17: Simulation Core PlayerId Non-Assumption + +### Test Name +`test_t0_17_noncontiguous_player_ids` + +### Test Location +`crates/server/tests/integration/t0_17_playerid_nonassumption.rs` + +### Setup +1. Start server with: + - `--test-mode` + - `--test-player-ids 17,99` +2. Connect two clients +3. Run complete match with movement inputs + +### Assertions +**Assignment correctness:** +1. First session receives `player_id=17` +2. Second session receives `player_id=99` +3. `ServerWelcome.player_id` reflects assigned ID + +**Movement correctness:** +4. Both players move correctly with their respective inputs +5. Entity positions update according to MOVE_SPEED formula +6. No special behavior for specific PlayerIds + +**Artifact correctness:** +7. `artifact.test_mode == true` +8. `artifact.test_player_ids == [17, 99]` +9. `artifact.entity_spawn_order == [17, 99]` +10. `artifact.player_entity_mapping` contains both (17, eid1) and (99, eid2) + +**Replay verification:** +11. Replay verification passes with non-contiguous IDs +12. Final digest matches + +**Negative check:** +13. Simulation Core code does NOT contain literals `0` or `1` as PlayerId assumptions + (verified via code scan or runtime assertion) + +### Constitution IDs Enforced +- **DM-0019** (PlayerId): No assumption of contiguous/zero-based IDs +- **INV-0007** (Deterministic Ordering): Ordering by player_id, not by assumed values + +### Fixtures Needed +- None + +--- + +## T0.18: Floor Coherency Server-Side Broadcast + +### Test Name +`test_t0_18_floor_coherency_broadcast` + +### Test Location +`crates/server/tests/integration/t0_18_floor_coherency.rs` + +### Setup +1. Start server in manual-step mode +2. Connect two clients +3. Advance world through several ticks +4. Capture SnapshotProto bytes before sending to each client + +### Assertions +**Byte-identical broadcast:** +1. For each tick T, capture serialized SnapshotProto bytes +2. Assert: `bytes_to_client_0 == bytes_to_client_1` (byte-for-byte) +3. This includes: + - `tick` + - `entities` (same order, same values) + - `digest` + - `target_tick_floor` + +**Server-side assertion:** +4. Server internally asserts payload identity before send +5. Alternatively: single serialization path (serialize once, send twice) + +**Floor value coherency:** +6. `SnapshotProto.target_tick_floor` is identical for both clients at each tick +7. Value matches expected: `world.tick() + input_lead_ticks` + +### Constitution IDs Enforced +- **ADR-0006** (Input Tick Targeting): v0 Floor Coherency normative constraint + +### Fixtures Needed +- None + +--- + +## T0.19: Schema Identity CI Gate + +### Test Name +`test_t0_19_schema_identity_ci` + +### Test Location +CI script: `scripts/verify_schema_identity.py` or `just` target +Cargo metadata check: `crates/*/Cargo.toml` + +### Setup +1. Build both server and client binaries +2. Extract dependency metadata using `cargo metadata` + +### Assertions +**Shared crate existence:** +1. Crate `flowstate_wire` exists at `crates/wire/` +2. Contains protobuf message definitions (prost derives) + +**Dependency verification:** +3. Server binary (`crates/server/`) depends on `flowstate_wire` +4. Client binary (`crates/client/`) depends on `flowstate_wire` +5. Both depend on **same package ID**: + - Same crate name + - Same version + - Same source (workspace member, not external) + +**CI gate:** +6. `just ci` includes schema identity check +7. Fail if either binary does not depend on shared wire crate +8. Fail if versions diverge (should not be possible with workspace) + +**Compile-time enforcement:** +9. Message types are imported from `flowstate_wire`, not redefined +10. No duplicate protobuf message definitions in client/server crates + +### Constitution IDs Enforced +- **ADR-0005** (v0 Networking Architecture): Same-build scope, shared schema + +### Fixtures Needed +- None + +--- + +## T0.20: `just ci` Passes + +### Test Name +`test_t0_20_just_ci_passes` + +### Test Location +CI pipeline / local execution + +### Setup +1. Clean workspace (optional: `cargo clean`) +2. Run `just ci` + +### Assertions +**All sub-commands pass:** +1. `just fmt` — formatting check passes (no changes needed) +2. `just lint` — clippy with `-D warnings` passes +3. `just test` — all unit and integration tests pass +4. `just ids` — Constitution ID validation passes +5. `just spec-lint` — spec structure validation passes + +**Exit code:** +6. `just ci` exits with code 0 + +**No warnings treated as errors:** +7. Clippy warnings fail the build (`-D warnings`) +8. No test failures or panics + +### Constitution IDs Enforced +- **AGENTS.md**: PR checklist requirement + +### Fixtures Needed +- None + +--- + +## Fixture Summary + +| Fixture | Path | Purpose | +|---------|------|---------| +| Movement golden file | `fixtures/movement/t0_04_60ticks_right.json` | Deterministic movement verification | +| Dependency allowlist | `scripts/allowed_sim_deps.txt` | Simulation Core isolation | +| Forbidden patterns | `scripts/forbidden_sim_patterns.txt` | API scan patterns | +| Expected artifact schema | `fixtures/replay/expected_artifact_schema.json` | Artifact field validation | +| Golden replay artifact | `fixtures/replay/t0_09_golden.replay` | Pre-verified replay for verification test | +| LKI expected sequence | `fixtures/lki/t0_12_expected.json` | LastKnownIntent verification | + +--- + +## Test Dependency Graph + +``` +T0.1 (handshake) + ├── T0.2 (baseline delivery) + └── T0.3 (input targeting) + ├── T0.4 (movement) ← T0.5 (isolation) + │ └── T0.5a (tick/floor relationship) + ├── T0.6 (validation) → T0.7 (robustness) + │ ├── T0.11 (future input) + │ ├── T0.12 (LKI determinism) + │ │ └── T0.12a (order robustness) + │ ├── T0.13 (validation matrix) + │ │ └── T0.13a (floor recovery) + │ └── T0.17 (PlayerId non-assumption) + └── T0.8 (artifact fields) + ├── T0.9 (replay verification) + │ └── T0.10 (anchor failure) + ├── T0.14 (disconnect) + └── T0.15 (match complete) + +T0.16 (timeout) ← independent +T0.18 (floor coherency) ← depends on T0.1 +T0.19 (schema identity) ← CI-level check +T0.20 (just ci) ← aggregates all +``` + +--- + +## Implementation Priority + +**Phase 1 (Foundation):** +- T0.5: Simulation Core isolation (blocks all sim work) +- T0.1: Handshake (blocks all client-server tests) +- T0.2: Baseline delivery + +**Phase 2 (Core Loop):** +- T0.4: Movement determinism +- T0.5a: Tick/floor relationship +- T0.3: Input targeting +- T0.6: Input validation + +**Phase 3 (Robustness):** +- T0.7: Malformed input handling +- T0.11: Future input buffering +- T0.12: LastKnownIntent +- T0.13: Validation matrix + +**Phase 4 (Replay):** +- T0.8: Artifact generation +- T0.9: Replay verification +- T0.10: Anchor failure +- T0.12a: Order robustness + +**Phase 5 (Lifecycle):** +- T0.14: Disconnect handling +- T0.15: Match termination +- T0.16: Connection timeout + +**Phase 6 (Polish):** +- T0.13a: Floor recovery +- T0.17: PlayerId non-assumption +- T0.18: Floor coherency +- T0.19: Schema identity +- T0.20: Full CI validation diff --git a/docs/impl/FS-0007-plan.md b/docs/impl/FS-0007-plan.md new file mode 100644 index 0000000..530c557 --- /dev/null +++ b/docs/impl/FS-0007-plan.md @@ -0,0 +1,390 @@ +# FS-0007: v0 Two-Client Multiplayer Slice — Implementation Plan + +> **Spec:** [FS-0007-v0-multiplayer-slice.md](../specs/FS-0007-v0-multiplayer-slice.md) +> **Issue:** [#7](https://github.com/project-flowstate/flowstate/issues/7) +> **Date:** 2025-12-23 + +--- + +## 1. Task Breakdown by Spec Section + +Tasks are ordered by dependency. Within each section, tasks are listed in implementation order. + +### 1.1 Simulation Core Types (`crates/sim/src/lib.rs`) + +| Task ID | Description | Dependencies | Est. Complexity | +|---------|-------------|--------------|-----------------| +| SIM-001 | Define type aliases: `Tick = u64`, `PlayerId = u8`, `EntityId = u64` | None | Trivial | +| SIM-002 | Implement `EntitySnapshot` struct with `entity_id`, `position: [f64; 2]`, `velocity: [f64; 2]` | SIM-001 | Trivial | +| SIM-003 | Implement `StepInput` struct with `player_id`, `move_dir: [f64; 2]` | SIM-001 | Trivial | +| SIM-004 | Implement `Baseline` struct with `tick`, `entities: Vec`, `digest: u64` | SIM-001, SIM-002 | Trivial | +| SIM-005 | Implement `Snapshot` struct (same fields as Baseline) | SIM-001, SIM-002 | Trivial | +| SIM-006 | Implement internal `Character` struct with `entity_id`, `player_id`, `position`, `velocity` | SIM-001 | Low | +| SIM-007 | Implement `World` struct with internal fields: `tick`, `tick_rate_hz`, `dt_seconds`, `entities` (character storage), `next_entity_id` counter | SIM-006 | Low | +| SIM-008 | Implement `World::new(seed, tick_rate_hz)` — initialize at tick 0, compute `dt_seconds` | SIM-007 | Low | +| SIM-009 | Implement `World::spawn_character(player_id) -> EntityId` — deterministic ID assignment | SIM-007 | Low | +| SIM-010 | Implement `World::tick()`, `World::tick_rate_hz()` accessors | SIM-007 | Trivial | +| SIM-011 | Implement f64 canonicalization: `-0.0 → +0.0`, `NaN → 0x7ff8000000000000` (ADR-0007) | None | Low | +| SIM-012 | Implement FNV-1a 64-bit hasher (offset `0xcbf29ce484222325`, prime `0x100000001b3`) | None | Low | +| SIM-013 | Implement `World::state_digest()` — hash tick + entities by EntityId ascending per ADR-0007 | SIM-011, SIM-012 | Medium | +| SIM-014 | Implement `World::baseline()` — return Baseline with sorted entities + digest | SIM-004, SIM-013 | Low | +| SIM-015 | Implement v0 movement physics in `World::advance()`: `MOVE_SPEED=5.0`, `velocity = move_dir * MOVE_SPEED`, `position += velocity * dt` | SIM-003, SIM-007 | Medium | +| SIM-016 | Implement `World::advance(tick, step_inputs) -> Snapshot` with precondition assert, tick increment, return Snapshot | SIM-015 | Medium | +| SIM-017 | Add `#![deny(unsafe_code)]` and document isolation constraints (INV-0004) | None | Trivial | + +### 1.2 v0 Movement Model + +| Task ID | Description | Dependencies | Est. Complexity | +|---------|-------------|--------------|-----------------| +| MOV-001 | Define `MOVE_SPEED = 5.0` constant (compile-time, recorded in ReplayArtifact) | SIM-015 | Trivial | +| MOV-002 | Implement `move_dir` magnitude clamping in advance() (defense-in-depth; validation is Server Edge) | SIM-015 | Low | +| MOV-003 | Verify deterministic f64 arithmetic — document fast-math disabled for sim crate | SIM-015 | Low | + +### 1.3 Protocol Messages (`crates/wire/`) + +| Task ID | Description | Dependencies | Est. Complexity | +|---------|-------------|--------------|-----------------| +| WIRE-001 | Create `crates/wire/` crate with `Cargo.toml`, add `prost` + `prost-types` dependencies | None | Low | +| WIRE-002 | Define `EntitySnapshotProto` message (prost derive) | WIRE-001 | Low | +| WIRE-003 | Define `ClientHello` message (empty for v0) | WIRE-001 | Trivial | +| WIRE-004 | Define `ServerWelcome` message: `target_tick_floor`, `tick_rate_hz`, `player_id`, `controlled_entity_id` | WIRE-001 | Low | +| WIRE-005 | Define `JoinBaseline` message: `tick`, `entities`, `digest` | WIRE-002 | Low | +| WIRE-006 | Define `InputCmdProto` message: `tick`, `input_seq`, `move_dir` (no `player_id`) | WIRE-001 | Low | +| WIRE-007 | Define `SnapshotProto` message: `tick`, `entities`, `digest`, `target_tick_floor` | WIRE-002 | Low | +| WIRE-008 | Define channel constants: `CHANNEL_REALTIME = 0`, `CHANNEL_CONTROL = 1` | WIRE-001 | Trivial | +| WIRE-009 | Add wire crate as dependency of sim crate (for shared types awareness only) | WIRE-001 | Trivial | + +### 1.4 Replay Artifact (`crates/replay/`) + +| Task ID | Description | Dependencies | Est. Complexity | +|---------|-------------|--------------|-----------------| +| REP-001 | Create `crates/replay/` crate with `Cargo.toml` | None | Low | +| REP-002 | Define `AppliedInput` struct: `tick`, `player_id`, `move_dir`, `is_fallback` | REP-001 | Low | +| REP-003 | Define `BuildFingerprint` struct: `binary_sha256`, `target_triple`, `profile`, `git_commit` | REP-001 | Low | +| REP-004 | Define `TuningParameter` struct: `key: String`, `value: String` | REP-001 | Trivial | +| REP-005 | Define `PlayerEntityMapping` struct: `player_id`, `entity_id` | REP-001 | Trivial | +| REP-006 | Define `ReplayArtifact` protobuf message with all required fields per spec | REP-002, REP-003, REP-004, REP-005 | Medium | +| REP-007 | Implement `ReplayArtifact::serialize()` — deterministic protobuf encoding | REP-006 | Low | +| REP-008 | Implement `ReplayArtifact::deserialize()` | REP-006 | Low | +| REP-009 | Implement BuildFingerprint acquisition at runtime: SHA-256 of executable, target triple, profile | REP-003 | Medium | +| REP-010 | Implement ReplayVerifier — initialization reconstruction, baseline digest check, replay loop, final digest check | REP-006, SIM-016 | High | +| REP-011 | Implement AppliedInput stream integrity validation (no gaps, no duplicates) | REP-010 | Medium | +| REP-012 | Add replay crate as dependency of server crate | REP-001 | Trivial | + +### 1.5 Server Edge (`crates/server/`) + +| Task ID | Description | Dependencies | Est. Complexity | +|---------|-------------|--------------|-----------------| +| SRV-001 | Create `crates/server/` crate with `Cargo.toml`, add enet-rs dependency | None | Low | +| SRV-002 | Implement ENet host initialization with two channels (Realtime, Control) | SRV-001 | Medium | +| SRV-003 | Implement Session struct: peer handle, `player_id`, `last_emitted_target_tick_floor`, `last_known_intent`, `last_valid_cmd_tick`, `input_seq` state | SRV-001 | Medium | +| SRV-004 | Implement connection accept loop with timeout (`connect_timeout_ms`) | SRV-002, SRV-003 | Medium | +| SRV-005 | Implement PlayerId assignment (v0 default: connection order 0, 1; test-mode override) | SRV-003 | Low | +| SRV-006 | Implement CLI parsing: `--seed`, `--replay-dir`, `--test-mode`, `--test-player-ids` | SRV-001 | Low | +| SRV-007 | Implement environment variable fallbacks: `FLOWSTATE_SEED`, `FLOWSTATE_REPLAY_DIR`, `FLOWSTATE_TEST_MODE`, `FLOWSTATE_TEST_PLAYER_IDS` | SRV-006 | Low | +| SRV-008 | Implement MatchId generation (16-64 chars, `[A-Za-z0-9_-]`) | SRV-001 | Low | +| SRV-009 | Implement handshake: receive ClientHello, send ServerWelcome + JoinBaseline | SRV-003, WIRE-003, WIRE-004, WIRE-005 | Medium | +| SRV-010 | Implement input buffer keyed by `(player_id, tick)` with window bounds | SRV-003 | Medium | +| SRV-011 | Implement InputSeq tracking with tie detection (`max_input_seq`, `max_seq_tied`) | SRV-010 | Medium | +| SRV-012 | Implement validation: NaN/Inf detection → DROP | SRV-010 | Low | +| SRV-013 | Implement validation: magnitude > 1.0 → CLAMP | SRV-010 | Low | +| SRV-014 | Implement validation: tick floor enforcement (`cmd.tick < last_emitted_target_tick_floor`) → DROP | SRV-010 | Low | +| SRV-015 | Implement validation: tick monotonicity (`cmd.tick < last_valid_cmd_tick`) → DROP | SRV-010 | Low | +| SRV-016 | Implement validation: tick window (`cmd.tick < current_tick` or `> current_tick + max_future_ticks`) → DROP | SRV-010 | Low | +| SRV-017 | Implement validation: rate limit per (session, tick) with `per_tick_limit = ceil(120/60) = 2` | SRV-010 | Medium | +| SRV-018 | Implement buffer cap: evict entries below window floor on tick advance | SRV-010 | Low | +| SRV-019 | Implement LastKnownIntent fallback (DM-0023): initial `[0,0]`, update on valid input | SRV-003 | Low | +| SRV-020 | Implement AppliedInput generation: buffer or LKI, record `is_fallback` | SRV-010, SRV-019, REP-002 | Medium | +| SRV-021 | Implement StepInput conversion from AppliedInput (sorted by player_id ascending) | SRV-020, SIM-003 | Low | +| SRV-022 | Implement TargetTickFloor computation: `world.tick() + input_lead_ticks` after advance | SRV-003 | Low | +| SRV-023 | Implement SnapshotProto broadcast (byte-identical to all sessions) | SRV-002, WIRE-007 | Medium | +| SRV-024 | Implement disconnect detection via ENet events | SRV-002 | Medium | +| SRV-025 | Implement match termination: complete tick, set `end_reason`, persist ReplayArtifact | SRV-024, REP-006 | Medium | +| SRV-026 | Implement pre-Welcome input drop (discard immediately without buffering) | SRV-009, SRV-010 | Low | +| SRV-027 | Add logging for all validation drops and protocol violations | SRV-012–SRV-017 | Low | + +### 1.6 Server Tick Loop + +| Task ID | Description | Dependencies | Est. Complexity | +|---------|-------------|--------------|-----------------| +| LOOP-001 | Implement tick loop pacing at `tick_rate_hz` (wall-clock for production) | SRV-002 | Medium | +| LOOP-002 | Implement manual-step mode for tests (no wall-clock pacing) | LOOP-001 | Low | +| LOOP-003 | Implement match duration check (`current_tick >= initial_tick + match_duration_ticks`) | LOOP-001 | Low | +| LOOP-004 | Implement per-tick receive and buffer cycle | SRV-010, LOOP-001 | Medium | +| LOOP-005 | Implement per-tick AppliedInput collection for all players | SRV-020, LOOP-004 | Medium | +| LOOP-006 | Implement per-tick advance call and snapshot broadcast | SIM-016, SRV-023, LOOP-005 | Medium | +| LOOP-007 | Implement per-tick ReplayArtifact input recording (canonical order) | REP-006, LOOP-005 | Low | +| LOOP-008 | Implement disconnect handling within tick loop (complete tick first) | SRV-024, LOOP-006 | Medium | + +### 1.7 Game Client (v0 Test Harness) + +| Task ID | Description | Dependencies | Est. Complexity | +|---------|-------------|--------------|-----------------| +| CLI-001 | Create minimal Rust test client binary or test module | WIRE-001 | Medium | +| CLI-002 | Implement ENet client connection and handshake | CLI-001, WIRE-003, WIRE-004 | Medium | +| CLI-003 | Implement JoinBaseline reception and state initialization | CLI-002, WIRE-005 | Medium | +| CLI-004 | Implement TargetTickFloor tracking: max(previous, received) | CLI-003 | Low | +| CLI-005 | Implement InputSeq generation (monotonically increasing) | CLI-001 | Low | +| CLI-006 | Implement InputCmdProto send with tick >= TargetTickFloor | CLI-004, CLI-005, WIRE-006 | Medium | +| CLI-007 | Implement SnapshotProto reception and state update | CLI-003, WIRE-007 | Medium | +| CLI-008 | Implement programmatic WASD input simulation for tests | CLI-006 | Low | +| CLI-009 | Implement test assertions: position matches expected deterministic values | CLI-007, SIM-015 | Medium | + +### 1.8 CI and Validation Infrastructure + +| Task ID | Description | Dependencies | Est. Complexity | +|---------|-------------|--------------|-----------------| +| CI-001 | Add sim crate dependency allowlist check (no I/O, no networking, no time) | SIM-017 | Medium | +| CI-002 | Add forbidden-API source scan for sim crate (file I/O, sockets, `std::time`, etc.) | SIM-017 | Medium | +| CI-003 | Add T0.19 schema identity gate: verify wire crate is shared dependency | WIRE-001 | Medium | +| CI-004 | Add replay verification integration test | REP-010 | High | +| CI-005 | Update Justfile: `just ci` includes all Tier-0 gates | CI-001–CI-004 | Low | + +--- + +## 2. Trace Matrix + +### 2.1 Acceptance Criteria → Implementation + +| Criterion | Description | Code Location(s) | Test(s) | +|-----------|-------------|------------------|---------| +| **AC-0001.1** | Two clients connect, handshake, receive Baseline, synchronized | `crates/server/src/session.rs`, `crates/server/src/handshake.rs` | `tests/integration/handshake_test.rs` | +| **AC-0001.2** | WASD movement, LastKnownIntent, TargetTickFloor targeting, snapshots | `crates/sim/src/lib.rs` (advance), `crates/server/src/input.rs`, `crates/server/src/tick_loop.rs` | `tests/integration/movement_test.rs`, `tests/unit/lki_test.rs` | +| **AC-0001.3** | Replay verification, Simulation Core isolation, fixed timestep | `crates/replay/src/verifier.rs`, `crates/sim/src/lib.rs` | `tests/integration/replay_test.rs`, CI dependency scan | +| **AC-0001.4** | Validation per v0-parameters, InputSeq selection, disconnect handling | `crates/server/src/validation.rs`, `crates/server/src/session.rs` | `tests/unit/validation_test.rs`, `tests/integration/disconnect_test.rs` | +| **AC-0001.5** | ReplayArtifact with all fields, reproduces outcome | `crates/replay/src/artifact.rs`, `crates/server/src/replay_writer.rs` | `tests/integration/replay_test.rs` | + +### 2.2 Tier-0 Gates → Implementation + +| Gate | Description | Code Location(s) | Test(s) | +|------|-------------|------------------|---------| +| **T0.1** | Two clients connect, complete handshake | `crates/server/src/handshake.rs`, `crates/server/src/session.rs` | `tests/t0/t0_01_handshake.rs` | +| **T0.2** | JoinBaseline delivers initial Baseline | `crates/server/src/handshake.rs`, `crates/wire/src/messages.rs` | `tests/t0/t0_02_baseline.rs` | +| **T0.3** | Clients tag inputs per ADR-0006 | `crates/server/src/validation.rs` (floor check), test client | `tests/t0/t0_03_input_tagging.rs` | +| **T0.4** | WASD produces movement with exact f64 equality | `crates/sim/src/lib.rs` (advance, movement) | `tests/t0/t0_04_movement.rs` | +| **T0.5** | Simulation Core isolation enforced | `crates/sim/Cargo.toml` (no I/O deps), CI scan | `tests/t0/t0_05_isolation.rs`, CI job | +| **T0.5a** | Tick/floor relationship assertion | `crates/server/src/tick_loop.rs` | `tests/t0/t0_05a_tick_floor.rs` | +| **T0.6** | Validation per v0-parameters.md | `crates/server/src/validation.rs` | `tests/t0/t0_06_validation.rs` | +| **T0.7** | Malformed inputs do not crash server | `crates/server/src/validation.rs` | `tests/t0/t0_07_malformed.rs` | +| **T0.8** | Replay artifact generated with all fields | `crates/replay/src/artifact.rs`, `crates/server/src/replay_writer.rs` | `tests/t0/t0_08_artifact_fields.rs` | +| **T0.9** | Replay verification passes | `crates/replay/src/verifier.rs` | `tests/t0/t0_09_replay_verify.rs` | +| **T0.10** | Initialization anchor failure test | `crates/replay/src/verifier.rs` | `tests/t0/t0_10_anchor_fail.rs` | +| **T0.11** | Future input non-interference | `crates/server/src/input.rs` | `tests/t0/t0_11_future_input.rs` | +| **T0.12** | LastKnownIntent determinism | `crates/server/src/input.rs`, `crates/replay/src/verifier.rs` | `tests/t0/t0_12_lki_determinism.rs` | +| **T0.12a** | Non-canonical AppliedInput storage order test | `crates/replay/src/verifier.rs` | `tests/t0/t0_12a_noncanonical.rs` | +| **T0.13** | Validation matrix | `crates/server/src/validation.rs` | `tests/t0/t0_13_validation_matrix.rs` | +| **T0.13a** | Floor enforcement drop and recovery | `crates/server/src/validation.rs`, test harness | `tests/t0/t0_13a_floor_recovery.rs` | +| **T0.14** | Disconnect handling | `crates/server/src/session.rs`, `crates/server/src/tick_loop.rs` | `tests/t0/t0_14_disconnect.rs` | +| **T0.15** | Match termination | `crates/server/src/tick_loop.rs` | `tests/t0/t0_15_match_end.rs` | +| **T0.16** | Connection timeout | `crates/server/src/main.rs` | `tests/t0/t0_16_timeout.rs` | +| **T0.17** | PlayerId non-assumption test | `crates/sim/src/lib.rs`, `crates/server/src/session.rs` | `tests/t0/t0_17_playerid.rs` | +| **T0.18** | Floor coherency broadcast | `crates/server/src/tick_loop.rs` | `tests/t0/t0_18_floor_coherency.rs` | +| **T0.19** | Schema identity CI gate | `crates/wire/Cargo.toml`, CI script | CI job `check_wire_shared.rs` | +| **T0.20** | `just ci` passes | All crates | `just ci` | + +--- + +## 3. File/Crate Inventory + +### 3.1 Files to Modify + +| Path | Purpose | Tasks | +|------|---------|-------| +| `crates/sim/src/lib.rs` | Replace stub with Simulation Core types and logic | SIM-001–SIM-017 | +| `crates/sim/Cargo.toml` | Add `#![deny(unsafe_code)]`, minimal deps only | SIM-017 | +| `Cargo.toml` (workspace) | Add new crate members: wire, server, replay | WIRE-001, SRV-001, REP-001 | +| `Justfile` | Add Tier-0 test commands, update `just ci` | CI-005 | +| `.gitignore` | Add `replays/` directory | REP-006 | + +### 3.2 New Crates to Create + +#### `crates/wire/` — Protocol Messages (shared) + +| File | Purpose | +|------|---------| +| `crates/wire/Cargo.toml` | Crate manifest with prost dependency | +| `crates/wire/src/lib.rs` | Module exports | +| `crates/wire/src/messages.rs` | All protobuf message definitions (prost derive) | +| `crates/wire/src/channels.rs` | Channel constants | + +#### `crates/server/` — Server Edge + +| File | Purpose | +|------|---------| +| `crates/server/Cargo.toml` | Crate manifest with enet-rs, wire, sim, replay deps | +| `crates/server/src/main.rs` | Entry point, CLI parsing, server bootstrap | +| `crates/server/src/lib.rs` | Module exports for testing | +| `crates/server/src/session.rs` | Session struct and management | +| `crates/server/src/handshake.rs` | Connection accept and handshake logic | +| `crates/server/src/input.rs` | Input buffer, InputSeq tracking, AppliedInput generation | +| `crates/server/src/validation.rs` | All input validation rules | +| `crates/server/src/tick_loop.rs` | Main tick loop, pacing, advance calls | +| `crates/server/src/replay_writer.rs` | ReplayArtifact recording during match | +| `crates/server/src/config.rs` | v0 parameters from v0-parameters.md | + +#### `crates/replay/` — Replay Artifact & Verification + +| File | Purpose | +|------|---------| +| `crates/replay/Cargo.toml` | Crate manifest with prost, sha2, sim deps | +| `crates/replay/src/lib.rs` | Module exports | +| `crates/replay/src/artifact.rs` | ReplayArtifact struct and serialization | +| `crates/replay/src/applied_input.rs` | AppliedInput struct | +| `crates/replay/src/fingerprint.rs` | BuildFingerprint acquisition | +| `crates/replay/src/verifier.rs` | Replay verification logic | + +### 3.3 Test Files to Create + +| Path | Purpose | +|------|---------| +| `crates/sim/src/tests/mod.rs` | Sim unit test module | +| `crates/sim/src/tests/digest_test.rs` | StateDigest algorithm tests (ADR-0007) | +| `crates/sim/src/tests/movement_test.rs` | v0 movement model tests | +| `crates/sim/src/tests/advance_test.rs` | World::advance() contract tests | +| `tests/t0/` | Tier-0 integration tests (all T0.* gates) | +| `tests/integration/` | End-to-end integration tests | + +### 3.4 CI/Infrastructure Files + +| Path | Purpose | +|------|---------| +| `scripts/check_sim_isolation.py` | Forbidden-API scan for sim crate | +| `scripts/check_wire_shared.py` | T0.19 schema identity verification | + +--- + +## 4. Definition of Done Checklist + +### 4.1 Acceptance Criteria (AC-0001) + +- [ ] **AC-0001.1:** Two native Game Clients connect to Game Server Instance, complete handshake (ServerWelcome with TargetTickFloor + tick_rate_hz + player_id + controlled_entity_id), receive JoinBaseline, remain synchronized (client state = last received authoritative Snapshot) +- [ ] **AC-0001.2:** WASD movement works; LastKnownIntent for missing inputs; TargetTickFloor-based targeting; both clients receive snapshots (one per tick) +- [ ] **AC-0001.3:** Replay verification passes (baseline + final digest); Simulation Core has no I/O; tick_rate_hz fixed at construction; advance() takes explicit tick + StepInput +- [ ] **AC-0001.4:** Validation per v0-parameters.md; InputSeq selection per Validation Rules; future inputs buffered correctly; player_id bound to session; disconnect → complete tick → persist artifact → shutdown; connection timeout aborts cleanly +- [ ] **AC-0001.5:** ReplayArtifact produced with all required fields; reproduces outcome on same build/platform + +### 4.2 Tier-0 Gates + +- [ ] **T0.1:** Two clients connect, complete handshake (ServerWelcome with TargetTickFloor + tick_rate_hz + player_id + controlled_entity_id) +- [ ] **T0.2:** JoinBaseline delivers initial Baseline; clients display Characters +- [ ] **T0.3:** Clients tag inputs per ADR-0006: InputCmd.tick >= TargetTickFloor, InputSeq monotonic +- [ ] **T0.4:** WASD produces movement with exact f64 equality (deterministic harness, N ticks, position.x increases by expected amount) +- [ ] **T0.5:** Simulation Core isolation enforced: crate separation, dependency allowlist (CI), forbidden-API scan (CI); advance() takes explicit tick per ADR-0003 +- [ ] **T0.5a:** Tick/floor relationship assertion: After world.advance(T, inputs), snapshot.tick == T+1, TargetTickFloor in SnapshotProto == snapshot.tick + input_lead_ticks +- [ ] **T0.6:** Validation per v0-parameters.md: magnitude clamp, NaN/Inf drop, tick window, rate limit, InputSeq selection, LastKnownIntent, player_id bound to session +- [ ] **T0.7:** Malformed inputs do not crash server +- [ ] **T0.8:** Replay artifact generated with all required fields +- [ ] **T0.9:** Replay verification: initialization reconstruction, baseline digest check, half-open range, final digest match +- [ ] **T0.10:** Initialization anchor failure: mutated baseline digest fails immediately after spawn reconstruction +- [ ] **T0.11:** Future input non-interference: input for T+k (k > window) rejected; T+1 input buffered without affecting T +- [ ] **T0.12:** LastKnownIntent determinism: input gaps filled, recorded in artifact, replay produces same digest +- [ ] **T0.12a:** Non-canonical AppliedInput storage order test: verifier canonicalizes successfully, dev warning allowed +- [ ] **T0.13:** Validation matrix: NaN, magnitude, tick window, rate limit (N > limit drops at least N-limit), InputSeq selection (tied → LKI fallback), TargetTickFloor enforcement, pre-Welcome input drop +- [ ] **T0.13a:** Floor enforcement drop and recovery test: snapshot loss → inputs dropped → recovery within bounded ticks +- [ ] **T0.14:** Disconnect handling: complete current tick, persist artifact with end_reason="disconnect", clean shutdown +- [ ] **T0.15:** Match termination: complete match reaches match_duration_ticks, artifact persisted with end_reason="complete" +- [ ] **T0.16:** Connection timeout: server aborts if < 2 sessions within connect_timeout_ms, non-zero exit code, no artifact +- [ ] **T0.17:** PlayerId non-assumption: `--test-mode --test-player-ids 17,99` produces correct movement and replay verification +- [ ] **T0.18:** Floor coherency: server broadcasts byte-identical SnapshotProto to all sessions per tick +- [ ] **T0.19:** Schema identity CI gate: wire crate is shared dependency of both server and client +- [ ] **T0.20:** `just ci` passes + +--- + +## 5. Dependency Graph (Critical Path) + +``` +SIM-001..SIM-017 (Simulation Core) + │ + ├──────────────────┐ + ▼ ▼ +WIRE-001..WIRE-009 REP-001..REP-012 +(Protocol Messages) (Replay Artifact) + │ │ + └────────┬─────────┘ + ▼ + SRV-001..SRV-027 + (Server Edge) + │ + ▼ + LOOP-001..LOOP-008 + (Tick Loop) + │ + ▼ + CLI-001..CLI-009 + (Test Client) + │ + ▼ + CI-001..CI-005 + (CI Gates) +``` + +**Critical Path:** SIM → WIRE → SRV → LOOP → CLI → CI + +**Parallelizable Work:** +- REP-001..REP-009 can proceed in parallel with WIRE-* after SIM-* completes +- CI-001..CI-003 can be developed in parallel with SRV-* + +--- + +## 6. Risk Areas and Notes + +### 6.1 High-Risk Items + +| Area | Risk | Mitigation | +|------|------|------------| +| f64 Determinism | Cross-compile or optimization flags may break determinism | Disable fast-math, test on CI target, document constraints | +| ENet Integration | enet-rs crate may have API quirks or platform issues | Prototype connection handling early, have fallback plan | +| StateDigest | FNV-1a implementation correctness | Unit test against known test vectors | +| BuildFingerprint | Executable file locking on Windows | Implement graceful fallback for dev mode | + +### 6.2 Implementation Notes + +1. **Seed = 0 Default:** Per spec, default seed is 0 for deterministic testing. All CI runs use this. + +2. **Test-Mode PlayerIds:** The `--test-mode --test-player-ids 17,99` feature is required for T0.17. Must be implemented early to validate Simulation Core boundary. + +3. **Manual Step Mode:** Server tick loop must support non-paced stepping for CI tests. Use a trait or flag pattern. + +4. **Byte-Identical Snapshots:** Serialize SnapshotProto once per tick, broadcast same bytes. This simplifies T0.18. + +5. **Validation Order:** Rate limiting and basic checks at receive-time, InputSeq selection at apply-time. + +--- + +## 7. Suggested Implementation Order + +### Phase 1: Foundation (Week 1) +1. SIM-001 through SIM-017 (Simulation Core complete) +2. Unit tests for StateDigest, movement, advance + +### Phase 2: Protocol (Week 1-2) +3. WIRE-001 through WIRE-009 (Protocol messages) +4. REP-001 through REP-009 (Replay artifact structures) + +### Phase 3: Server Core (Week 2-3) +5. SRV-001 through SRV-011 (Session, handshake, input buffer) +6. SRV-012 through SRV-021 (Validation, AppliedInput) +7. LOOP-001 through LOOP-008 (Tick loop) + +### Phase 4: Integration (Week 3-4) +8. SRV-022 through SRV-027 (Broadcast, disconnect, replay write) +9. REP-010 through REP-012 (Replay verification) +10. CLI-001 through CLI-009 (Test client) + +### Phase 5: Validation (Week 4) +11. CI-001 through CI-005 (CI gates) +12. All T0.* tests +13. Final integration testing + +--- + +## References + +- [FS-0007 Spec](../specs/FS-0007-v0-multiplayer-slice.md) +- [v0 Parameters](../networking/v0-parameters.md) +- [ADR-0006: Input Tick Targeting](../adr/0006-input-tick-targeting.md) +- [ADR-0007: StateDigest Algorithm](../adr/0007-state-digest-algorithm-canonical-serialization.md) +- [Invariants](../constitution/invariants.md) +- [Domain Model](../constitution/domain-model.md) +- [Acceptance Criteria](../constitution/acceptance-kill.md) diff --git a/docs/licensing/third-party.md b/docs/licensing/third-party.md index 0560e77..0a95913 100644 --- a/docs/licensing/third-party.md +++ b/docs/licensing/third-party.md @@ -109,7 +109,8 @@ Every third-party dependency or asset must be recorded below. | Name | Version / Commit | License | Source URL | Usage Scope | Notes | |----|----|----|----|----|----| -| | | | | | | +| prost | 0.13 | Apache-2.0 | https://crates.io/crates/prost | Runtime dependency | Protobuf serialization for wire protocol | +| sha2 | 0.10 | MIT OR Apache-2.0 | https://crates.io/crates/sha2 | Runtime dependency | SHA-256 for build fingerprint | **Usage Scope examples** - Runtime dependency diff --git a/docs/specs/FS-0007-v0-multiplayer-slice.md b/docs/specs/FS-0007-v0-multiplayer-slice.md index 823fc2d..7cc47de 100644 --- a/docs/specs/FS-0007-v0-multiplayer-slice.md +++ b/docs/specs/FS-0007-v0-multiplayer-slice.md @@ -1,12 +1,12 @@ --- -status: Approved +status: Implemented issue: 7 title: v0 Two-Client Multiplayer Slice --- # FS-0007: v0 Two-Client Multiplayer Slice -> **Status:** Approved +> **Status:** Implemented > **Issue:** [#7](https://github.com/project-flowstate/flowstate/issues/7) > **Owner:** @danieldilly > **Date:** 2025-12-21