use std::collections::BTreeMap;
use crate::generated::{
SecondaryCard, SecondaryCardAwardsItem, SecondaryCardAwardsItemVariant0Mode,
SecondaryCardAwardsItemVariant1Mode,
};
pub const TACTICAL_CARD_CAP: u64 = 5;
pub const ROUNDS: usize = 5;
pub const GAME_VP_CAP: u64 = 100;
#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Copy, Debug, Eq, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum ScoringMode {
Fixed,
Tactical,
}
#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug, PartialEq)]
pub struct AssertedAward {
pub award: SecondaryCardAwardsItem,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub count: Option<u64>,
}
#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Copy, Debug, Default, PartialEq)]
pub struct RoundCell {
pub primary: u64,
pub secondary: u64,
}
#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ScoreEntry {
pub card_id: String,
pub round: u64,
pub vp: u64,
}
#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PlayerGame {
pub approach: ScoringMode,
pub hand_ids: Vec<String>,
pub rounds: Vec<RoundCell>,
pub log: Vec<ScoreEntry>,
}
pub fn empty_player_game(approach: ScoringMode) -> PlayerGame {
PlayerGame {
approach,
hand_ids: Vec::new(),
rounds: vec![RoundCell::default(); ROUNDS],
log: Vec::new(),
}
}
pub fn awards_of(card: &SecondaryCard) -> &[SecondaryCardAwardsItem] {
&card.awards
}
fn award_mode(a: &SecondaryCardAwardsItem) -> Option<ScoringMode> {
match a {
SecondaryCardAwardsItem::Variant0 { mode, .. } => mode.as_ref().map(|m| match m {
SecondaryCardAwardsItemVariant0Mode::Fixed => ScoringMode::Fixed,
SecondaryCardAwardsItemVariant0Mode::Tactical => ScoringMode::Tactical,
}),
SecondaryCardAwardsItem::Variant1 { mode, .. } => mode.as_ref().map(|m| match m {
SecondaryCardAwardsItemVariant1Mode::Fixed => ScoringMode::Fixed,
SecondaryCardAwardsItemVariant1Mode::Tactical => ScoringMode::Tactical,
}),
}
}
fn award_exclusive_group(a: &SecondaryCardAwardsItem) -> Option<&str> {
match a {
SecondaryCardAwardsItem::Variant0 {
exclusive_group, ..
} => exclusive_group.as_deref().map(String::as_str),
SecondaryCardAwardsItem::Variant1 {
exclusive_group, ..
} => exclusive_group.as_deref().map(String::as_str),
}
}
fn award_vp_max(a: &SecondaryCardAwardsItem) -> Option<u64> {
match a {
SecondaryCardAwardsItem::Variant0 { vp_max, .. }
| SecondaryCardAwardsItem::Variant1 { vp_max, .. } => vp_max.map(|n| n.get()),
}
}
pub fn awards_for_approach(
card: &SecondaryCard,
approach: ScoringMode,
) -> Vec<&SecondaryCardAwardsItem> {
awards_of(card)
.iter()
.filter(|a| award_mode(a).map(|m| m == approach).unwrap_or(true))
.collect()
}
pub fn score_award(award: &SecondaryCardAwardsItem, count: u64) -> u64 {
match award {
SecondaryCardAwardsItem::Variant0 { vp, .. } => *vp,
SecondaryCardAwardsItem::Variant1 {
vp_per, per_max, ..
} => {
let capped = match per_max {
Some(pm) => count.min(pm.get()),
None => count,
};
vp_per * capped
}
}
}
pub fn score_turn(asserted: &[AssertedAward]) -> u64 {
let mut group_best: BTreeMap<&str, u64> = BTreeMap::new();
let mut total: u64 = 0;
for aa in asserted {
let v = score_award(&aa.award, aa.count.unwrap_or(1));
match award_exclusive_group(&aa.award) {
Some(g) => {
let entry = group_best.entry(g).or_insert(0);
if v > *entry {
*entry = v;
}
}
None => total += v,
}
}
total + group_best.values().sum::<u64>()
}
pub fn score_cap(card: &SecondaryCard, approach: ScoringMode) -> Option<u64> {
if approach == ScoringMode::Tactical {
return Some(TACTICAL_CARD_CAP);
}
awards_for_approach(card, ScoringMode::Fixed)
.iter()
.filter_map(|a| award_vp_max(a))
.max()
}
pub fn score_secondary_event(
asserted: &[AssertedAward],
card: &SecondaryCard,
approach: ScoringMode,
) -> u64 {
let turn = score_turn(asserted);
match score_cap(card, approach) {
Some(c) => turn.min(c),
None => turn,
}
}
pub fn score_primary_event(asserted: &[AssertedAward], round_cap: u64) -> u64 {
score_turn(asserted).min(round_cap)
}
fn round_index(round: u64) -> usize {
(round.max(1) - 1).min(ROUNDS as u64 - 1) as usize
}
pub fn record_secondary(pg: &PlayerGame, round: u64, vp: u64) -> PlayerGame {
let i = round_index(round);
let mut next = pg.clone();
next.rounds[i].secondary += vp;
next
}
pub fn score_secondary(pg: &PlayerGame, round: u64, card_id: &str, vp: u64) -> PlayerGame {
let mut next = record_secondary(pg, round, vp);
next.hand_ids.retain(|id| id != card_id);
next.log.push(ScoreEntry {
card_id: card_id.to_string(),
round,
vp,
});
next
}
pub fn remove_score(pg: &PlayerGame, index: usize) -> PlayerGame {
let Some(entry) = pg.log.get(index) else {
return pg.clone();
};
let entry = entry.clone();
let i = round_index(entry.round);
let mut next = pg.clone();
next.rounds[i].secondary = next.rounds[i].secondary.saturating_sub(entry.vp);
next.log.remove(index);
if !next.hand_ids.contains(&entry.card_id) {
next.hand_ids.push(entry.card_id);
}
next
}
pub fn set_primary(
pg: &PlayerGame,
round: u64,
vp: u64,
round_cap: Option<u64>,
game_cap: Option<u64>,
) -> PlayerGame {
let i = round_index(round);
let others: u64 = pg
.rounds
.iter()
.enumerate()
.filter(|(idx, _)| *idx != i)
.map(|(_, c)| c.primary)
.sum();
let game_room = game_cap
.map(|g| g.saturating_sub(others))
.unwrap_or(u64::MAX);
let room = round_cap.unwrap_or(u64::MAX).min(game_room);
let mut next = pg.clone();
next.rounds[i].primary = vp.min(room);
next
}
pub fn add_to_hand(pg: &PlayerGame, card_id: &str) -> PlayerGame {
let mut next = pg.clone();
if !next.hand_ids.iter().any(|id| id == card_id) {
next.hand_ids.push(card_id.to_string());
}
next
}
pub fn remove_from_hand(pg: &PlayerGame, card_id: &str) -> PlayerGame {
let mut next = pg.clone();
next.hand_ids.retain(|id| id != card_id);
next
}
pub fn player_primary(pg: &PlayerGame) -> u64 {
pg.rounds.iter().map(|c| c.primary).sum()
}
pub fn player_secondary(pg: &PlayerGame) -> u64 {
pg.rounds.iter().map(|c| c.secondary).sum()
}
pub fn player_total(pg: &PlayerGame) -> u64 {
GAME_VP_CAP.min(player_primary(pg) + player_secondary(pg))
}
#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Copy, Debug, Eq, PartialEq)]
pub struct WtcResult {
pub a: u64,
pub b: u64,
}
pub fn wtc_result(total_a: u64, total_b: u64) -> WtcResult {
if total_a == total_b {
return WtcResult { a: 10, b: 10 };
}
let diff = total_a.abs_diff(total_b);
let band = if diff <= 5 {
0
} else {
(diff - 5).div_ceil(5).min(10)
};
let winner = 10 + band;
let loser = 10 - band;
if total_a > total_b {
WtcResult {
a: winner,
b: loser,
}
} else {
WtcResult {
a: loser,
b: winner,
}
}
}
#[cfg(all(test, feature = "bundled-data"))]
mod tests {
use super::*;
use crate::Dataset;
fn ds() -> &'static Dataset {
Dataset::embedded()
}
fn card(ds: &Dataset, id: &str) -> SecondaryCard {
ds.mission_cards
.get(id)
.unwrap_or_else(|| panic!("fixture card missing: {id}"))
.clone()
}
fn assert_all(
card: &SecondaryCard,
approach: ScoringMode,
counts: &[u64],
) -> Vec<AssertedAward> {
awards_for_approach(card, approach)
.into_iter()
.enumerate()
.map(|(i, a)| AssertedAward {
award: a.clone(),
count: counts.get(i).copied(),
})
.collect()
}
#[test]
fn score_award_flat_and_per() {
let ds = ds();
let no_prisoners = card(ds, "no-prisoners");
let per_kill = awards_for_approach(&no_prisoners, ScoringMode::Tactical)[0].clone();
assert_eq!(score_award(&per_kill, 3), 6);
}
#[test]
fn exclusive_group_takes_highest() {
let ds = ds();
let engage = card(ds, "engage-on-all-fronts");
let asserted = assert_all(&engage, ScoringMode::Tactical, &[]);
assert_eq!(score_turn(&asserted), 5);
}
#[test]
fn cumulative_and_independent_sum() {
let ds = ds();
let assassination = card(ds, "assassination");
let fixed = awards_for_approach(&assassination, ScoringMode::Fixed);
let asserted = vec![
AssertedAward {
award: fixed[0].clone(),
count: Some(2),
},
AssertedAward {
award: fixed[1].clone(),
count: Some(1),
},
];
assert_eq!(score_turn(&asserted), 7);
}
#[test]
fn caps_tactical_at_five_and_fixed_at_vp_max() {
let ds = ds();
let burden = card(ds, "burden-of-trust");
assert_eq!(score_cap(&burden, ScoringMode::Tactical), Some(5));
assert_eq!(score_cap(&burden, ScoringMode::Fixed), Some(9));
let assassination = card(ds, "assassination");
assert_eq!(score_cap(&assassination, ScoringMode::Fixed), None);
let per_obj = awards_for_approach(&burden, ScoringMode::Fixed)[0].clone();
let asserted = vec![AssertedAward {
award: per_obj,
count: Some(10),
}];
assert_eq!(
score_secondary_event(&asserted, &burden, ScoringMode::Fixed),
9
);
}
#[test]
fn primary_event_clamps_to_round_cap() {
let ds = ds();
let no_prisoners = card(ds, "no-prisoners");
let per_kill = awards_for_approach(&no_prisoners, ScoringMode::Tactical)[0].clone();
let asserted = vec![AssertedAward {
award: per_kill,
count: Some(8),
}];
assert_eq!(score_primary_event(&asserted, 15), 15);
}
#[test]
fn set_primary_respects_round_and_game_caps() {
let mut pg = empty_player_game(ScoringMode::Tactical);
pg = set_primary(&pg, 1, 30, Some(15), None);
assert_eq!(pg.rounds[0].primary, 15);
let mut g = empty_player_game(ScoringMode::Tactical);
for r in [1u64, 2, 3] {
g = set_primary(&g, r, 15, Some(15), Some(45));
}
assert_eq!(player_primary(&g), 45);
g = set_primary(&g, 4, 15, Some(15), Some(45));
assert_eq!(g.rounds[3].primary, 0);
assert_eq!(player_primary(&g), 45);
}
#[test]
fn caps_grand_total_at_100() {
let mut pg = empty_player_game(ScoringMode::Tactical);
for r in 1..=ROUNDS as u64 {
pg = set_primary(&pg, r, 30, None, None);
}
assert_eq!(player_primary(&pg), 150);
assert_eq!(player_total(&pg), GAME_VP_CAP);
}
#[test]
fn score_secondary_logs_and_discards_and_remove_undoes() {
let mut pg = add_to_hand(&empty_player_game(ScoringMode::Tactical), "centre-ground");
pg = score_secondary(&pg, 1, "centre-ground", 5);
assert_eq!(pg.rounds[0].secondary, 5);
assert!(pg.hand_ids.is_empty());
assert_eq!(pg.log.len(), 1);
pg = remove_score(&pg, 0);
assert_eq!(pg.rounds[0].secondary, 0);
assert!(pg.log.is_empty());
assert_eq!(pg.hand_ids, vec!["centre-ground".to_string()]);
assert_eq!(remove_score(&pg, 9), pg);
}
#[test]
fn wtc_bands() {
assert_eq!(wtc_result(50, 50), WtcResult { a: 10, b: 10 });
assert_eq!(wtc_result(48, 45), WtcResult { a: 10, b: 10 }); assert_eq!(wtc_result(56, 50), WtcResult { a: 11, b: 9 }); assert_eq!(wtc_result(50, 61), WtcResult { a: 8, b: 12 }); assert_eq!(wtc_result(100, 50), WtcResult { a: 19, b: 1 }); assert_eq!(wtc_result(100, 49), WtcResult { a: 20, b: 0 }); assert_eq!(wtc_result(0, 100), WtcResult { a: 0, b: 20 });
}
#[test]
fn player_game_round_trips_through_json() {
let mut pg = empty_player_game(ScoringMode::Tactical);
pg = set_primary(&pg, 1, 8, Some(15), Some(45));
pg = add_to_hand(&pg, "centre-ground");
pg = record_secondary(&pg, 1, 5);
let json = serde_json::to_string(&pg).unwrap();
let back: PlayerGame = serde_json::from_str(&json).unwrap();
assert_eq!(back, pg);
}
}