feat: wire bark battle platform loop
Some checks are pending
CI / verify (pull_request) Waiting to run

This commit is contained in:
2026-05-14 18:20:46 +08:00
parent 8c6ec9e6e4
commit 1d7ef7e4b6
73 changed files with 7933 additions and 107 deletions

View 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 }

View 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,
}

View File

@@ -0,0 +1,5 @@
pub mod domain;
pub mod scoring;
pub use domain::*;
pub use scoring::*;

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