use crate::domain::*; pub fn validate_finish_metrics( ruleset: &BarkBattleRuleset, metrics: &BarkBattleFinishMetrics, ) -> FinishValidation { let mut flags = Vec::new(); let mut rejected = false; if metrics.duration_ms < ruleset.min_duration_ms { flags.push(AntiCheatFlag::DurationTooShort); rejected = true; } if metrics.duration_ms > ruleset.max_duration_ms { flags.push(AntiCheatFlag::DurationTooLong); rejected = true; } let max_trigger_count = metrics.duration_ms / ruleset.min_bark_gap_ms + u64::from(ruleset.trigger_count_tolerance); if metrics.trigger_count > max_trigger_count { flags.push(AntiCheatFlag::TriggerCountTooHigh); } if !is_in_range(metrics.max_volume, ruleset.min_volume, ruleset.max_volume) { flags.push(AntiCheatFlag::MaxVolumeOutOfRange); rejected = true; } if !is_in_range( metrics.average_volume, ruleset.min_average_volume, ruleset.max_average_volume, ) { flags.push(AntiCheatFlag::AverageVolumeOutOfRange); rejected = true; } if !is_in_range( metrics.final_energy, ruleset.min_final_energy, ruleset.max_final_energy, ) { flags.push(AntiCheatFlag::FinalEnergyOutOfRange); rejected = true; } if metrics.max_combo < ruleset.min_combo || metrics.max_combo > ruleset.max_combo { flags.push(AntiCheatFlag::MaxComboOutOfRange); rejected = true; } let decision = if rejected { FinishValidationDecision::Rejected } else if flags.is_empty() { FinishValidationDecision::Accepted } else { FinishValidationDecision::AcceptedWithFlags }; FinishValidation { decision, anti_cheat_flags: flags, } } pub fn adjudicate_result( ruleset: &BarkBattleRuleset, player_final_energy: f32, opponent_final_energy: f32, ) -> BattleResult { let delta = player_final_energy - opponent_final_energy; if delta.abs() <= ruleset.draw_threshold_energy as f32 { BattleResult::Draw } else if delta > 0.0 { BattleResult::PlayerWin } else { BattleResult::OpponentWin } } pub fn compute_leaderboard_score( ruleset: &BarkBattleRuleset, metrics: &BarkBattleFinishMetrics, validation: &FinishValidation, result: BattleResult, ) -> Option { if result != BattleResult::PlayerWin || validation.decision == FinishValidationDecision::Rejected { return None; } Some(BarkBattleLeaderboardScore { final_energy_millis: to_millis(metrics.final_energy), trigger_count: metrics.trigger_count, max_volume_millis: to_millis(metrics.max_volume), duration_closeness_ms: metrics.duration_ms.abs_diff(ruleset.standard_duration_ms), finished_at_micros: metrics.finished_at_micros, }) } fn is_in_range(value: f32, min: f32, max: f32) -> bool { value.is_finite() && value >= min && value <= max } fn to_millis(value: f32) -> u32 { (value * 1_000.0).round().clamp(0.0, u32::MAX as f32) as u32 } #[cfg(test)] mod tests { use crate::*; fn metrics(duration_ms: u64) -> BarkBattleFinishMetrics { BarkBattleFinishMetrics { duration_ms, trigger_count: 10, max_volume: 0.8, average_volume: 0.6, final_energy: 60.0, max_combo: 5, finished_at_micros: 1_000_000, } } #[test] fn serde_uses_contract_snake_case_for_domain_enums() { assert_eq!( serde_json::to_value(DifficultyPreset::Easy).expect("serialize difficulty"), serde_json::json!("easy") ); assert_eq!( serde_json::to_value(FinishValidationDecision::AcceptedWithFlags) .expect("serialize decision"), serde_json::json!("accepted_with_flags") ); assert_eq!( serde_json::to_value(AntiCheatFlag::AverageVolumeOutOfRange) .expect("serialize anti-cheat flag"), serde_json::json!("average_volume_out_of_range") ); assert_eq!( serde_json::to_value(BattleResult::PlayerWin).expect("serialize battle result"), serde_json::json!("player_win") ); } #[test] fn accepts_duration_inside_28s_to_35s_window() { let ruleset = BarkBattleRuleset::v1(); assert_eq!( validate_finish_metrics(&ruleset, &metrics(28_000)).decision, FinishValidationDecision::Accepted ); assert_eq!( validate_finish_metrics(&ruleset, &metrics(35_000)).decision, FinishValidationDecision::Accepted ); } #[test] fn rejects_or_flags_extreme_duration() { let ruleset = BarkBattleRuleset::v1(); assert_ne!( validate_finish_metrics(&ruleset, &metrics(1_000)).decision, FinishValidationDecision::Accepted ); assert_ne!( validate_finish_metrics(&ruleset, &metrics(300_000)).decision, FinishValidationDecision::Accepted ); } #[test] fn flags_trigger_count_above_physical_limit_with_tolerance() { let ruleset = BarkBattleRuleset::v1(); let mut input = metrics(30_000); input.trigger_count = input.duration_ms / ruleset.min_bark_gap_ms + u64::from(ruleset.trigger_count_tolerance) + 1; let validation = validate_finish_metrics(&ruleset, &input); assert_eq!( validation.decision, FinishValidationDecision::AcceptedWithFlags ); assert!( validation .anti_cheat_flags .contains(&AntiCheatFlag::TriggerCountTooHigh) ); } #[test] fn rejects_final_energy_outside_range() { let ruleset = BarkBattleRuleset::v1(); let mut input = metrics(30_000); input.final_energy = ruleset.max_final_energy + 0.1; let validation = validate_finish_metrics(&ruleset, &input); assert_eq!(validation.decision, FinishValidationDecision::Rejected); assert!( validation .anti_cheat_flags .contains(&AntiCheatFlag::FinalEnergyOutOfRange) ); } #[test] fn leaderboard_score_only_for_player_win_and_not_rejected() { let ruleset = BarkBattleRuleset::v1(); let input = metrics(30_000); let validation = validate_finish_metrics(&ruleset, &input); assert!( compute_leaderboard_score(&ruleset, &input, &validation, BattleResult::PlayerWin) .is_some() ); assert!( compute_leaderboard_score(&ruleset, &input, &validation, BattleResult::Draw).is_none() ); assert!( compute_leaderboard_score(&ruleset, &input, &validation, BattleResult::OpponentWin) .is_none() ); let rejected = FinishValidation { decision: FinishValidationDecision::Rejected, anti_cheat_flags: vec![AntiCheatFlag::DurationTooShort], }; assert!( compute_leaderboard_score(&ruleset, &input, &rejected, BattleResult::PlayerWin) .is_none() ); } #[test] fn adjudicates_draw_threshold_boundaries() { let ruleset = BarkBattleRuleset::v1(); assert_eq!(adjudicate_result(&ruleset, 53.0, 50.0), BattleResult::Draw); assert_eq!( adjudicate_result(&ruleset, 53.1, 50.0), BattleResult::PlayerWin ); assert_eq!( adjudicate_result(&ruleset, 46.9, 50.0), BattleResult::OpponentWin ); } #[test] fn validates_inclusive_metric_boundaries_and_rejects_non_finite() { let ruleset = BarkBattleRuleset::v1(); let mut input = metrics(30_000); input.trigger_count = input.duration_ms / ruleset.min_bark_gap_ms + u64::from(ruleset.trigger_count_tolerance); input.max_volume = ruleset.min_volume; input.final_energy = ruleset.max_final_energy; input.max_combo = ruleset.max_combo; assert_eq!( validate_finish_metrics(&ruleset, &input).decision, FinishValidationDecision::Accepted ); input.max_volume = f32::NAN; assert_eq!( validate_finish_metrics(&ruleset, &input).decision, FinishValidationDecision::Rejected ); input.max_volume = 0.8; input.average_volume = ruleset.max_average_volume + 0.1; let validation = validate_finish_metrics(&ruleset, &input); assert_eq!(validation.decision, FinishValidationDecision::Rejected); assert!( validation .anti_cheat_flags .contains(&AntiCheatFlag::AverageVolumeOutOfRange) ); input.average_volume = 0.6; input.final_energy = f32::INFINITY; assert_eq!( validate_finish_metrics(&ruleset, &input).decision, FinishValidationDecision::Rejected ); } #[test] fn leaderboard_score_allows_flagged_but_accepted_player_wins() { let ruleset = BarkBattleRuleset::v1(); let input = metrics(30_000); let validation = FinishValidation { decision: FinishValidationDecision::AcceptedWithFlags, anti_cheat_flags: vec![AntiCheatFlag::TriggerCountTooHigh], }; assert!( compute_leaderboard_score(&ruleset, &input, &validation, BattleResult::PlayerWin) .is_some() ); } #[test] fn difficulty_changes_only_ai_preset_key() { let easy = BarkBattleRuleset::for_difficulty(DifficultyPreset::Easy); let normal = BarkBattleRuleset::for_difficulty(DifficultyPreset::Normal); let hard = BarkBattleRuleset::for_difficulty(DifficultyPreset::Hard); assert_eq!(easy.thresholds_signature(), normal.thresholds_signature()); assert_eq!(normal.thresholds_signature(), hard.thresholds_signature()); assert_eq!(easy.ai_preset_key, "bark-battle-ai-easy"); assert_eq!(normal.ai_preset_key, "bark-battle-ai-normal"); assert_eq!(hard.ai_preset_key, "bark-battle-ai-hard"); } }