Files
Genarrative/server-rs/crates/module-bark-battle/src/scoring.rs
kdletters 1d7ef7e4b6
Some checks failed
CI / verify (pull_request) Has been cancelled
feat: wire bark battle platform loop
2026-05-14 18:20:46 +08:00

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");
}
}