feat: wire bark battle platform loop
Some checks are pending
CI / verify (pull_request) Waiting to run
Some checks are pending
CI / verify (pull_request) Waiting to run
This commit is contained in:
14
server-rs/crates/module-bark-battle/Cargo.toml
Normal file
14
server-rs/crates/module-bark-battle/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "module-bark-battle"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
162
server-rs/crates/module-bark-battle/src/domain.rs
Normal file
162
server-rs/crates/module-bark-battle/src/domain.rs
Normal file
@@ -0,0 +1,162 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const BARK_BATTLE_RULESET_VERSION_V1: &str = "bark-battle-ruleset-v1";
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DifficultyPreset {
|
||||
Easy,
|
||||
Normal,
|
||||
Hard,
|
||||
}
|
||||
|
||||
impl DifficultyPreset {
|
||||
pub fn ai_preset_key(self) -> &'static str {
|
||||
match self {
|
||||
Self::Easy => "bark-battle-ai-easy",
|
||||
Self::Normal => "bark-battle-ai-normal",
|
||||
Self::Hard => "bark-battle-ai-hard",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RulesetThresholdsSignature {
|
||||
pub standard_duration_ms: u64,
|
||||
pub min_duration_ms: u64,
|
||||
pub max_duration_ms: u64,
|
||||
pub min_bark_gap_ms: u64,
|
||||
pub trigger_count_tolerance: u32,
|
||||
pub min_volume: f32,
|
||||
pub max_volume: f32,
|
||||
pub min_average_volume: f32,
|
||||
pub max_average_volume: f32,
|
||||
pub min_final_energy: f32,
|
||||
pub max_final_energy: f32,
|
||||
pub min_combo: u32,
|
||||
pub max_combo: u32,
|
||||
pub draw_threshold_energy: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BarkBattleRuleset {
|
||||
pub version: &'static str,
|
||||
pub difficulty: DifficultyPreset,
|
||||
pub ai_preset_key: &'static str,
|
||||
pub standard_duration_ms: u64,
|
||||
pub min_duration_ms: u64,
|
||||
pub max_duration_ms: u64,
|
||||
pub min_bark_gap_ms: u64,
|
||||
pub trigger_count_tolerance: u32,
|
||||
pub min_volume: f32,
|
||||
pub max_volume: f32,
|
||||
pub min_average_volume: f32,
|
||||
pub max_average_volume: f32,
|
||||
pub min_final_energy: f32,
|
||||
pub max_final_energy: f32,
|
||||
pub min_combo: u32,
|
||||
pub max_combo: u32,
|
||||
pub draw_threshold_energy: u32,
|
||||
}
|
||||
|
||||
impl BarkBattleRuleset {
|
||||
pub fn v1() -> Self {
|
||||
Self::for_difficulty(DifficultyPreset::Normal)
|
||||
}
|
||||
|
||||
pub fn for_difficulty(difficulty: DifficultyPreset) -> Self {
|
||||
Self {
|
||||
version: BARK_BATTLE_RULESET_VERSION_V1,
|
||||
difficulty,
|
||||
ai_preset_key: difficulty.ai_preset_key(),
|
||||
standard_duration_ms: 30_000,
|
||||
min_duration_ms: 28_000,
|
||||
max_duration_ms: 35_000,
|
||||
min_bark_gap_ms: 250,
|
||||
trigger_count_tolerance: 2,
|
||||
min_volume: 0.0,
|
||||
max_volume: 1.0,
|
||||
min_average_volume: 0.0,
|
||||
max_average_volume: 1.0,
|
||||
min_final_energy: 0.0,
|
||||
max_final_energy: 100.0,
|
||||
min_combo: 0,
|
||||
max_combo: 999,
|
||||
draw_threshold_energy: 3,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn thresholds_signature(&self) -> RulesetThresholdsSignature {
|
||||
RulesetThresholdsSignature {
|
||||
standard_duration_ms: self.standard_duration_ms,
|
||||
min_duration_ms: self.min_duration_ms,
|
||||
max_duration_ms: self.max_duration_ms,
|
||||
min_bark_gap_ms: self.min_bark_gap_ms,
|
||||
trigger_count_tolerance: self.trigger_count_tolerance,
|
||||
min_volume: self.min_volume,
|
||||
max_volume: self.max_volume,
|
||||
min_average_volume: self.min_average_volume,
|
||||
max_average_volume: self.max_average_volume,
|
||||
min_final_energy: self.min_final_energy,
|
||||
max_final_energy: self.max_final_energy,
|
||||
min_combo: self.min_combo,
|
||||
max_combo: self.max_combo,
|
||||
draw_threshold_energy: self.draw_threshold_energy,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BarkBattleFinishMetrics {
|
||||
pub duration_ms: u64,
|
||||
pub trigger_count: u64,
|
||||
/// 归一化音量,合法范围为 0.0..=1.0。
|
||||
pub max_volume: f32,
|
||||
pub average_volume: f32,
|
||||
pub final_energy: f32,
|
||||
pub max_combo: u32,
|
||||
pub finished_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum FinishValidationDecision {
|
||||
Accepted,
|
||||
AcceptedWithFlags,
|
||||
Rejected,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AntiCheatFlag {
|
||||
DurationTooShort,
|
||||
DurationTooLong,
|
||||
TriggerCountTooHigh,
|
||||
MaxVolumeOutOfRange,
|
||||
AverageVolumeOutOfRange,
|
||||
FinalEnergyOutOfRange,
|
||||
MaxComboOutOfRange,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FinishValidation {
|
||||
pub decision: FinishValidationDecision,
|
||||
pub anti_cheat_flags: Vec<AntiCheatFlag>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BattleResult {
|
||||
PlayerWin,
|
||||
OpponentWin,
|
||||
Draw,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct BarkBattleLeaderboardScore {
|
||||
pub final_energy_millis: u32,
|
||||
pub trigger_count: u64,
|
||||
pub max_volume_millis: u32,
|
||||
pub duration_closeness_ms: u64,
|
||||
pub finished_at_micros: i64,
|
||||
}
|
||||
5
server-rs/crates/module-bark-battle/src/lib.rs
Normal file
5
server-rs/crates/module-bark-battle/src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod domain;
|
||||
pub mod scoring;
|
||||
|
||||
pub use domain::*;
|
||||
pub use scoring::*;
|
||||
316
server-rs/crates/module-bark-battle/src/scoring.rs
Normal file
316
server-rs/crates/module-bark-battle/src/scoring.rs
Normal file
@@ -0,0 +1,316 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user