317 lines
10 KiB
Rust
317 lines
10 KiB
Rust
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<BarkBattleLeaderboardScore> {
|
|
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");
|
|
}
|
|
}
|