#[derive(Debug, Clone, Default)]
pub struct ClassCount {
pub alive: usize,
pub total: usize,
pub hp: f32,
pub max_hp: f32,
}
#[derive(Debug, Clone)]
pub struct TeamState {
pub score: i64,
pub uncontested_caps: usize,
pub total_hp: f32,
pub max_hp: f32,
pub ships_alive: usize,
pub ships_total: usize,
pub ships_known: usize,
pub destroyers: ClassCount,
pub cruisers: ClassCount,
pub battleships: ClassCount,
pub submarines: ClassCount,
pub carriers: ClassCount,
}
impl Default for TeamState {
fn default() -> Self {
TeamState {
score: 0,
uncontested_caps: 0,
total_hp: 0.0,
max_hp: 0.0,
ships_alive: 0,
ships_total: 0,
ships_known: 0,
destroyers: ClassCount::default(),
cruisers: ClassCount::default(),
battleships: ClassCount::default(),
submarines: ClassCount::default(),
carriers: ClassCount::default(),
}
}
}
impl TeamState {
pub fn new() -> Self {
Self::default()
}
}
pub use wowsunpack::game_types::AdvantageLevel;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TeamAdvantage {
Team0(AdvantageLevel),
Team1(AdvantageLevel),
Even,
}
impl TeamAdvantage {
fn for_team(team: usize, level: AdvantageLevel) -> Self {
if team == 0 { TeamAdvantage::Team0(level) } else { TeamAdvantage::Team1(level) }
}
}
#[derive(Debug, Clone)]
pub struct ScoringParams {
pub team_win_score: i64,
pub hold_reward: i64,
pub hold_period: f32,
}
#[derive(Debug, Clone, Default)]
#[cfg_attr(feature = "rkyv", derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize))]
pub struct AdvantageBreakdown {
pub score_projection: (f32, f32),
pub fleet_power: (f32, f32),
pub strategic_threat: (f32, f32),
pub total: (f32, f32),
pub hp_data_reliable: bool,
pub team_eliminated: bool,
pub team0_pps: f64,
pub team1_pps: f64,
}
#[derive(Debug, Clone)]
pub struct AdvantageResult {
pub advantage: TeamAdvantage,
pub breakdown: AdvantageBreakdown,
}
impl AdvantageResult {
fn even() -> Self {
AdvantageResult { advantage: TeamAdvantage::Even, breakdown: AdvantageBreakdown::default() }
}
}
pub fn swap_breakdown(bd: &mut AdvantageBreakdown) {
swap_tuple(&mut bd.score_projection);
swap_tuple(&mut bd.fleet_power);
swap_tuple(&mut bd.strategic_threat);
swap_tuple(&mut bd.total);
std::mem::swap(&mut bd.team0_pps, &mut bd.team1_pps);
}
fn swap_tuple(t: &mut (f32, f32)) {
std::mem::swap(&mut t.0, &mut t.1);
}
const WEIGHT_DESTROYER: f32 = 1.5;
const WEIGHT_CRUISER: f32 = 1.0;
const WEIGHT_BATTLESHIP: f32 = 1.0;
const WEIGHT_SUBMARINE: f32 = 1.3;
const WEIGHT_CARRIER: f32 = 1.2;
const MAX_SCORE_PROJECTION: f32 = 10.0;
const MAX_FLEET_POWER: f32 = 10.0;
const MAX_STRATEGIC_THREAT: f32 = 5.0;
fn fleet_power(team: &TeamState) -> f32 {
let class_power = |cc: &ClassCount, weight: f32| -> f32 {
if cc.alive == 0 || cc.max_hp <= 0.0 {
return 0.0;
}
let hp_fraction = cc.hp / cc.max_hp;
weight * cc.alive as f32 * hp_fraction
};
class_power(&team.destroyers, WEIGHT_DESTROYER)
+ class_power(&team.cruisers, WEIGHT_CRUISER)
+ class_power(&team.battleships, WEIGHT_BATTLESHIP)
+ class_power(&team.submarines, WEIGHT_SUBMARINE)
+ class_power(&team.carriers, WEIGHT_CARRIER)
}
pub fn calculate_advantage(
team0: &TeamState,
team1: &TeamState,
scoring: &ScoringParams,
time_left: Option<i64>,
) -> AdvantageResult {
if team0.ships_total == 0 || team1.ships_total == 0 {
return AdvantageResult::even();
}
let hp_data_reliable = team0.ships_known == team0.ships_total && team1.ships_known == team1.ships_total;
if hp_data_reliable {
if team0.ships_alive == 0 && team1.ships_alive > 0 {
return AdvantageResult {
advantage: TeamAdvantage::Team1(AdvantageLevel::Absolute),
breakdown: AdvantageBreakdown {
team_eliminated: true,
hp_data_reliable: true,
total: (0.0, MAX_SCORE_PROJECTION + MAX_FLEET_POWER + MAX_STRATEGIC_THREAT),
..Default::default()
},
};
}
if team1.ships_alive == 0 && team0.ships_alive > 0 {
return AdvantageResult {
advantage: TeamAdvantage::Team0(AdvantageLevel::Absolute),
breakdown: AdvantageBreakdown {
team_eliminated: true,
hp_data_reliable: true,
total: (MAX_SCORE_PROJECTION + MAX_FLEET_POWER + MAX_STRATEGIC_THREAT, 0.0),
..Default::default()
},
};
}
if team0.ships_alive == 0 && team1.ships_alive == 0 {
return AdvantageResult::even();
}
}
let mut bd = AdvantageBreakdown { hp_data_reliable, ..Default::default() };
let pps0 = if scoring.hold_period > 0.0 {
team0.uncontested_caps as f64 * scoring.hold_reward as f64 / scoring.hold_period as f64
} else {
0.0
};
let pps1 = if scoring.hold_period > 0.0 {
team1.uncontested_caps as f64 * scoring.hold_reward as f64 / scoring.hold_period as f64
} else {
0.0
};
bd.team0_pps = pps0;
bd.team1_pps = pps1;
let seconds_left = time_left.unwrap_or(0).max(0) as f64;
let win = scoring.team_win_score as f64;
let proj0 = (team0.score as f64 + pps0 * seconds_left).min(win);
let proj1 = (team1.score as f64 + pps1 * seconds_left).min(win);
let ttw = |score: i64, pps: f64| -> Option<f64> {
let remaining = win - score as f64;
if remaining <= 0.0 {
Some(0.0)
} else if pps > 0.0 {
Some(remaining / pps)
} else {
None
}
};
let ttw0 = ttw(team0.score, pps0);
let ttw1 = ttw(team1.score, pps1);
let mut score_pts: (f32, f32) = (0.0, 0.0);
let score_gap = (team0.score - team1.score).abs() as f64;
if score_gap > 0.0 {
let gap_pts = (score_gap / win * 4.0).min(4.0) as f32;
if team0.score > team1.score {
score_pts.0 += gap_pts;
} else {
score_pts.1 += gap_pts;
}
}
match (ttw0, ttw1) {
(Some(t0), Some(t1)) if t0 < seconds_left && t1 < seconds_left => {
let time_diff = (t1 - t0).abs();
let ttw_pts = if time_diff > 30.0 {
3.0
} else if time_diff > 10.0 {
2.0
} else if time_diff > 3.0 {
1.0
} else {
0.0
};
if t0 < t1 {
score_pts.0 += ttw_pts;
} else {
score_pts.1 += ttw_pts;
}
}
(Some(t0), _) if t0 < seconds_left => {
score_pts.0 += 3.0; }
(_, Some(t1)) if t1 < seconds_left => {
score_pts.1 += 3.0; }
_ => {}
}
let proj_gap = (proj0 - proj1).abs();
if proj_gap > 0.0 {
let proj_pts = if proj_gap >= 300.0 {
3.0
} else if proj_gap >= 150.0 {
2.0
} else if proj_gap >= 50.0 {
1.0
} else {
0.0
};
if proj0 > proj1 {
score_pts.0 += proj_pts;
} else {
score_pts.1 += proj_pts;
}
}
bd.score_projection = (score_pts.0.min(MAX_SCORE_PROJECTION), score_pts.1.min(MAX_SCORE_PROJECTION));
if hp_data_reliable {
let power0 = fleet_power(team0);
let power1 = fleet_power(team1);
let total_power = power0 + power1;
if total_power > 0.0 {
let frac0 = power0 / total_power;
let frac1 = power1 / total_power;
bd.fleet_power = (frac0 * MAX_FLEET_POWER, frac1 * MAX_FLEET_POWER);
}
}
if hp_data_reliable {
let mut threat0: f32 = 0.0;
let mut threat1: f32 = 0.0;
let time_weight = (seconds_left / 300.0).clamp(0.2, 1.0) as f32;
let dd_ss_score = |team: &TeamState| -> f32 {
let dd_alive = team.destroyers.alive as f32;
let ss_alive = team.submarines.alive as f32;
(dd_alive * 1.0 + ss_alive * 0.8).min(2.5)
};
let dd_ss0 = dd_ss_score(team0) * time_weight;
let dd_ss1 = dd_ss_score(team1) * time_weight;
threat0 += dd_ss0;
threat1 += dd_ss1;
let diversity = |team: &TeamState| -> f32 {
let mut classes = 0u32;
if team.destroyers.alive > 0 {
classes += 1;
}
if team.cruisers.alive > 0 {
classes += 1;
}
if team.battleships.alive > 0 {
classes += 1;
}
if team.submarines.alive > 0 {
classes += 1;
}
if team.carriers.alive > 0 {
classes += 1;
}
match classes {
0..=1 => 0.0,
2 => 0.5,
3 => 1.0,
_ => 1.5,
}
};
threat0 += diversity(team0);
threat1 += diversity(team1);
let cv_diff = team0.carriers.alive as i32 - team1.carriers.alive as i32;
if cv_diff > 0 {
threat0 += 1.0;
} else if cv_diff < 0 {
threat1 += 1.0;
}
bd.strategic_threat = (threat0.min(MAX_STRATEGIC_THREAT), threat1.min(MAX_STRATEGIC_THREAT));
}
let total0 = bd.score_projection.0 + bd.fleet_power.0 + bd.strategic_threat.0;
let total1 = bd.score_projection.1 + bd.fleet_power.1 + bd.strategic_threat.1;
bd.total = (total0, total1);
let gap = (total0 - total1).abs();
let team = if total0 > total1 { 0 } else { 1 };
let advantage = if gap >= 10.0 {
TeamAdvantage::for_team(team, AdvantageLevel::Absolute)
} else if gap >= 6.0 {
TeamAdvantage::for_team(team, AdvantageLevel::Strong)
} else if gap >= 3.0 {
TeamAdvantage::for_team(team, AdvantageLevel::Moderate)
} else if gap >= 1.0 {
TeamAdvantage::for_team(team, AdvantageLevel::Weak)
} else {
TeamAdvantage::Even
};
AdvantageResult { advantage, breakdown: bd }
}
#[cfg(test)]
mod tests {
use super::*;
fn default_scoring() -> ScoringParams {
ScoringParams { team_win_score: 1000, hold_reward: 3, hold_period: 5.0 }
}
fn even_team(score: i64, caps: usize) -> TeamState {
TeamState {
score,
uncontested_caps: caps,
total_hp: 100000.0,
max_hp: 100000.0,
ships_alive: 12,
ships_total: 12,
ships_known: 12,
destroyers: ClassCount { alive: 3, total: 3, hp: 15000.0, max_hp: 15000.0 },
cruisers: ClassCount { alive: 4, total: 4, hp: 40000.0, max_hp: 40000.0 },
battleships: ClassCount { alive: 4, total: 4, hp: 40000.0, max_hp: 40000.0 },
submarines: ClassCount { alive: 1, total: 1, hp: 5000.0, max_hp: 5000.0 },
carriers: ClassCount::default(),
}
}
#[test]
fn even_game_start() {
let t0 = even_team(0, 0);
let t1 = even_team(0, 0);
let r = calculate_advantage(&t0, &t1, &default_scoring(), Some(1200));
assert_eq!(r.advantage, TeamAdvantage::Even);
assert!((r.breakdown.total.0 - r.breakdown.total.1).abs() < 0.01);
}
#[test]
fn team_eliminated() {
let mut t1 = even_team(300, 0);
t1.ships_alive = 0;
t1.total_hp = 0.0;
let t0 = TeamState { ships_alive: 8, ..even_team(500, 2) };
let r = calculate_advantage(&t0, &t1, &default_scoring(), Some(600));
assert_eq!(r.advantage, TeamAdvantage::Team0(AdvantageLevel::Absolute));
assert!(r.breakdown.team_eliminated);
}
#[test]
fn team_eliminated_other() {
let mut t0 = even_team(300, 0);
t0.ships_alive = 0;
t0.total_hp = 0.0;
let t1 = TeamState { ships_alive: 5, ..even_team(400, 3) };
let r = calculate_advantage(&t0, &t1, &default_scoring(), Some(600));
assert_eq!(r.advantage, TeamAdvantage::Team1(AdvantageLevel::Absolute));
assert!(r.breakdown.team_eliminated);
}
#[test]
fn all_breakdown_values_non_negative() {
let t0 = even_team(700, 3);
let t1 = even_team(250, 0);
let r = calculate_advantage(&t0, &t1, &default_scoring(), Some(600));
assert!(r.breakdown.score_projection.0 >= 0.0);
assert!(r.breakdown.score_projection.1 >= 0.0);
assert!(r.breakdown.fleet_power.0 >= 0.0);
assert!(r.breakdown.fleet_power.1 >= 0.0);
assert!(r.breakdown.strategic_threat.0 >= 0.0);
assert!(r.breakdown.strategic_threat.1 >= 0.0);
assert!(r.breakdown.total.0 >= 0.0);
assert!(r.breakdown.total.1 >= 0.0);
}
#[test]
fn score_gap_gives_points_to_leader() {
let t0 = even_team(700, 2);
let t1 = even_team(250, 1);
let r = calculate_advantage(&t0, &t1, &default_scoring(), Some(300));
assert!(r.breakdown.score_projection.0 > r.breakdown.score_projection.1);
assert!(matches!(r.advantage, TeamAdvantage::Team0(_)));
}
#[test]
fn cap_advantage_projects_win() {
let t0 = even_team(0, 3);
let t1 = even_team(0, 0);
let r = calculate_advantage(&t0, &t1, &default_scoring(), Some(1200));
assert!(matches!(r.advantage, TeamAdvantage::Team0(_)));
assert!(r.breakdown.score_projection.0 > r.breakdown.score_projection.1);
}
#[test]
fn fleet_power_12v6_strong_advantage() {
let t0 = even_team(400, 1);
let mut t1 = even_team(400, 1);
t1.ships_alive = 6;
t1.ships_known = 12;
t1.total_hp = 50000.0;
t1.destroyers = ClassCount { alive: 1, total: 3, hp: 5000.0, max_hp: 5000.0 };
t1.cruisers = ClassCount { alive: 2, total: 4, hp: 20000.0, max_hp: 20000.0 };
t1.battleships = ClassCount { alive: 2, total: 4, hp: 20000.0, max_hp: 20000.0 };
t1.submarines = ClassCount { alive: 1, total: 1, hp: 5000.0, max_hp: 5000.0 };
let r = calculate_advantage(&t0, &t1, &default_scoring(), Some(600));
assert!(r.breakdown.fleet_power.0 > r.breakdown.fleet_power.1);
assert!(matches!(
r.advantage,
TeamAdvantage::Team0(AdvantageLevel::Moderate | AdvantageLevel::Strong | AdvantageLevel::Absolute)
));
}
#[test]
fn fleet_power_2v1_less_extreme() {
let t0 = TeamState {
score: 800,
uncontested_caps: 1,
total_hp: 80000.0,
max_hp: 80000.0,
ships_alive: 2,
ships_total: 12,
ships_known: 12,
destroyers: ClassCount::default(),
cruisers: ClassCount::default(),
battleships: ClassCount { alive: 2, total: 4, hp: 80000.0, max_hp: 80000.0 },
submarines: ClassCount::default(),
carriers: ClassCount::default(),
};
let t1 = TeamState {
score: 800,
uncontested_caps: 1,
total_hp: 50000.0,
max_hp: 50000.0,
ships_alive: 1,
ships_total: 12,
ships_known: 12,
destroyers: ClassCount::default(),
cruisers: ClassCount::default(),
battleships: ClassCount { alive: 1, total: 4, hp: 50000.0, max_hp: 50000.0 },
submarines: ClassCount::default(),
carriers: ClassCount::default(),
};
let r = calculate_advantage(&t0, &t1, &default_scoring(), Some(300));
let power_gap = r.breakdown.fleet_power.0 - r.breakdown.fleet_power.1;
assert!(power_gap > 0.0);
assert!(power_gap < 8.0);
}
#[test]
fn dd_survival_gives_threat_points() {
let t0 = TeamState { destroyers: ClassCount { alive: 0, total: 3, hp: 0.0, max_hp: 0.0 }, ..even_team(700, 2) };
let t1 = TeamState {
destroyers: ClassCount { alive: 2, total: 3, hp: 10000.0, max_hp: 10000.0 },
..even_team(400, 1)
};
let r = calculate_advantage(&t0, &t1, &default_scoring(), Some(600));
assert!(r.breakdown.strategic_threat.1 > r.breakdown.strategic_threat.0);
}
#[test]
fn submarine_hard_to_kill() {
let t0 = TeamState { submarines: ClassCount::default(), ..even_team(600, 2) };
let t1 = TeamState {
submarines: ClassCount { alive: 1, total: 1, hp: 5000.0, max_hp: 5000.0 },
..even_team(500, 1)
};
let r = calculate_advantage(&t0, &t1, &default_scoring(), Some(600));
assert!(r.breakdown.strategic_threat.1 > 0.0);
}
#[test]
fn class_diversity_bonus() {
let t0 = TeamState {
ships_alive: 3,
total_hp: 30000.0,
max_hp: 30000.0,
destroyers: ClassCount { alive: 1, total: 1, hp: 5000.0, max_hp: 5000.0 },
cruisers: ClassCount { alive: 1, total: 1, hp: 10000.0, max_hp: 10000.0 },
battleships: ClassCount { alive: 1, total: 1, hp: 15000.0, max_hp: 15000.0 },
submarines: ClassCount::default(),
carriers: ClassCount::default(),
..even_team(500, 1)
};
let t1 = TeamState {
ships_alive: 3,
total_hp: 45000.0,
max_hp: 45000.0,
destroyers: ClassCount::default(),
cruisers: ClassCount::default(),
battleships: ClassCount { alive: 3, total: 3, hp: 45000.0, max_hp: 45000.0 },
submarines: ClassCount::default(),
carriers: ClassCount::default(),
..even_team(500, 1)
};
let r = calculate_advantage(&t0, &t1, &default_scoring(), Some(600));
assert!(r.breakdown.strategic_threat.0 > r.breakdown.strategic_threat.1);
}
#[test]
fn no_time_left_limits_score_projection() {
let t0 = even_team(800, 0);
let t1 = even_team(700, 4);
let r = calculate_advantage(&t0, &t1, &default_scoring(), Some(5));
assert!(r.breakdown.score_projection.0 > r.breakdown.score_projection.1);
}
#[test]
fn incomplete_entity_data_skips_fleet_and_threat() {
let t0 = even_team(0, 0);
let mut t1 = even_team(0, 0);
t1.ships_known = 1;
let r = calculate_advantage(&t0, &t1, &default_scoring(), Some(1200));
assert!(!r.breakdown.hp_data_reliable);
assert_eq!(r.breakdown.fleet_power, (0.0, 0.0));
assert_eq!(r.breakdown.strategic_threat, (0.0, 0.0));
}
#[test]
fn swap_breakdown_flips_tuples() {
let mut bd = AdvantageBreakdown {
score_projection: (7.0, 2.0),
fleet_power: (6.0, 4.0),
strategic_threat: (3.0, 1.0),
total: (16.0, 7.0),
team0_pps: 1.2,
team1_pps: 0.6,
..Default::default()
};
swap_breakdown(&mut bd);
assert_eq!(bd.score_projection, (2.0, 7.0));
assert_eq!(bd.fleet_power, (4.0, 6.0));
assert_eq!(bd.strategic_threat, (1.0, 3.0));
assert_eq!(bd.total, (7.0, 16.0));
assert!((bd.team0_pps - 0.6).abs() < 0.01);
assert!((bd.team1_pps - 1.2).abs() < 0.01);
}
}