use std::collections::{HashMap, HashSet};
use crate::{
bonus_track::{ALL_TRACKS, Reward},
game::GameRef,
pack::{Pack, PackItem},
party::{ALL_PARTIES, Palette, Party},
storage::PushHistory,
};
const PACK_ACCRUAL_RATE: u64 = 25;
#[derive(Debug, Clone, PartialEq)]
pub struct State {
pub party_points: u64,
pub lifetime_points_earned: u64,
pub bonus_tracks: HashMap<String, u32>,
pub unlocked_parties: HashSet<String>,
pub enabled_parties: HashSet<String>,
pub unlocked_palettes: HashMap<String, Vec<String>>,
pub active_palettes: HashMap<String, PaletteSelection>,
pub packs: HashMap<Pack, u32>,
pub lifetime_packs_earned: u64,
pub games: HashMap<String, u32>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PaletteSelection {
Specific(String), Random,
}
impl Default for PaletteSelection {
fn default() -> Self {
Self::Specific(Palette::WHITE_ANSI.id().to_string())
}
}
impl Default for State {
fn default() -> Self {
let mut bonus_tracks = HashMap::new();
bonus_tracks.insert("commit_value".to_string(), 1);
let mut unlocked_parties = HashSet::new();
unlocked_parties.insert("base".to_string());
let white = Palette::WHITE_ANSI.id().to_string();
Self {
party_points: 0,
lifetime_points_earned: 0,
bonus_tracks,
enabled_parties: unlocked_parties.clone(),
unlocked_parties,
unlocked_palettes: HashMap::from([("base".to_string(), vec![white.clone()])]),
active_palettes: HashMap::from([(
"base".to_string(),
PaletteSelection::Specific(white),
)]),
packs: HashMap::new(),
games: HashMap::new(),
lifetime_packs_earned: 0,
}
}
}
impl State {
#[expect(clippy::too_many_arguments)]
pub fn new(
party_points: u64,
lifetime_points_earned: u64,
lifetime_packs_earned: u64,
bonus_tracks: HashMap<String, u32>,
unlocked_parties: HashSet<String>,
enabled_parties: HashSet<String>,
unlocked_palettes: HashMap<String, Vec<String>>,
active_palettes: HashMap<String, PaletteSelection>,
packs: HashMap<Pack, u32>,
games: HashMap<String, u32>,
) -> Self {
Self {
party_points,
lifetime_points_earned,
bonus_tracks,
unlocked_parties,
enabled_parties,
unlocked_palettes,
active_palettes,
packs,
games,
lifetime_packs_earned,
}
}
pub fn earn_points(&mut self, amount: u64) -> Vec<u64> {
self.party_points += amount;
self.lifetime_points_earned += amount;
let mut thresholds = Vec::new();
let mut threshold =
PACK_ACCRUAL_RATE * (self.lifetime_packs_earned + 1) * (self.lifetime_packs_earned + 2)
/ 2;
while threshold <= self.lifetime_points_earned {
self.lifetime_packs_earned += 1;
self.add_pack(Pack::Basic);
thresholds.push(threshold);
threshold = PACK_ACCRUAL_RATE
* (self.lifetime_packs_earned + 1)
* (self.lifetime_packs_earned + 2)
/ 2
}
thresholds
}
pub fn bonus_level(&self, id: &str) -> u32 {
self.bonus_tracks.get(id).copied().unwrap_or(0)
}
pub fn set_bonus_level(&mut self, id: &str, level: u32) {
self.bonus_tracks.insert(id.to_string(), level);
}
pub fn points_per_commit(&self) -> u64 {
let level = self.bonus_level("commit_value");
if level == 0 {
return 1;
}
for track in ALL_TRACKS.iter() {
if track.id() == "commit_value"
&& let Some(Reward::FlatPoints(n)) = track.reward_at_level(level)
{
return n;
}
}
1
}
pub fn unlocked_parties(&self) -> impl Iterator<Item = &'static dyn Party> + use<'_> {
ALL_PARTIES
.iter()
.copied()
.filter(|&party| self.is_party_unlocked(party.id()))
}
pub fn is_party_unlocked(&self, id: &str) -> bool {
self.unlocked_parties.contains(id)
}
pub fn is_party_enabled(&self, id: &str) -> bool {
self.unlocked_parties.contains(id) && self.enabled_parties.contains(id)
}
pub fn unlock_party(&mut self, id: &str) {
self.unlocked_parties.insert(id.to_string());
self.enabled_parties.insert(id.to_string());
if !self.unlocked_palettes.contains_key(id) {
let white = Palette::WHITE_ANSI.id().to_string();
self.unlocked_palettes
.insert(id.to_string(), vec![white.clone()]);
self.active_palettes
.insert(id.to_string(), PaletteSelection::Specific(white));
}
}
pub fn toggle_party(&mut self, id: &str) {
if self.unlocked_parties.contains(id) {
if self.enabled_parties.contains(id) {
self.enabled_parties.remove(id);
} else {
self.enabled_parties.insert(id.to_string());
}
}
}
pub fn unlock_palette(&mut self, party_id: &str, palette_name: &str) {
self.unlocked_palettes
.entry(party_id.to_string())
.and_modify(|v| {
v.push(palette_name.to_string());
})
.or_insert(Vec::from([palette_name.to_string()]));
}
pub fn is_palette_unlocked(&self, party_id: &str, palette_name: &str) -> bool {
self.unlocked_palettes
.get(party_id)
.is_some_and(|v| v.iter().any(|name| name == palette_name))
}
pub fn unlocked_palettes(&self, party_id: &str) -> Option<&Vec<String>> {
self.unlocked_palettes.get(party_id)
}
pub fn selected_palette(&self, party_id: &str) -> Option<&PaletteSelection> {
self.active_palettes.get(party_id)
}
pub fn selected_palette_idx(&self, party_id: &str) -> usize {
let Some(palettes) = self.unlocked_palettes(party_id) else {
return 0;
};
let Some(selected) = self.selected_palette(party_id) else {
return 0;
};
match selected {
PaletteSelection::Specific(palette_name) => palettes
.iter()
.position(|name| *name == *palette_name)
.unwrap_or(0),
PaletteSelection::Random => palettes.len(),
}
}
pub fn set_selected_palette(&mut self, party_id: &str, palette_idx: usize) {
let palettes = self.unlocked_palettes(party_id);
let palette_name = palettes.and_then(|palettes| palettes.get(palette_idx));
let selection = match palette_name {
Some(name) => PaletteSelection::Specific(name.to_string()),
None => PaletteSelection::Random,
};
self.active_palettes.insert(party_id.to_string(), selection);
}
pub fn add_pack(&mut self, pack: Pack) {
self.packs.entry(pack).and_modify(|n| *n += 1).or_insert(1);
}
pub fn pack_count(&self, pack: &Pack) -> u32 {
self.packs.get(pack).copied().unwrap_or_default()
}
pub fn pack_total(&self) -> u32 {
self.packs.values().sum()
}
pub fn open_pack(&mut self, pack: Pack) -> Vec<PackItem> {
self.packs
.entry(pack)
.and_modify(|n| *n = n.saturating_sub(1));
pack.open(self)
}
pub fn add_game_token(&mut self, game: GameRef) {
self.games
.entry(game.id().to_string())
.and_modify(|n| *n += 1)
.or_insert(1);
}
pub fn deduct_game_token(&mut self, game: GameRef) {
self.games
.entry(game.id().to_string())
.and_modify(|n| *n = n.saturating_sub(1));
}
pub fn game_token_count(&self, game: GameRef) -> u32 {
self.games.get(game.id()).copied().unwrap_or_default()
}
pub fn game_token_total(&self) -> u32 {
self.games.values().sum()
}
}
pub fn points(state: &State) {
println!("You have {} party points.", state.party_points);
}
pub fn stats(state: &State, history: &PushHistory) {
if !state.is_party_unlocked("stats") {
println!("You haven't unlocked the Stats party yet.");
return;
}
let clock = crate::clock::Clock::from_now();
let push = crate::git::Push::default();
let breakdown = crate::scoring::PointsBreakdown {
commits: 0,
points_per_commit: 0,
total: 0,
applied: vec![],
};
let ctx =
crate::party::RenderContext::new(&push, history, &breakdown, state, &clock, Vec::new());
crate::party::stats::Stats.render(&ctx, &crate::party::Palette::WHITE_ANSI);
}
pub fn dump(state: &State) {
println!("party_points: {}", state.party_points);
println!("lifetime_points_earned: {}", state.lifetime_points_earned);
println!("lifetime_packs_earned: {}", state.lifetime_packs_earned);
println!("points_per_commit: {}", state.points_per_commit());
println!("bonus_levels: {:?}", state.bonus_tracks);
println!("unlocked_parties: {:?}", state.unlocked_parties);
println!("enabled_parties: {:?}", state.enabled_parties);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_state_has_zero_points_or_packs() {
let state = State::default();
assert_eq!(state.party_points, 0);
assert_eq!(state.lifetime_points_earned, 0);
assert_eq!(state.packs.values().count(), 0)
}
#[test]
fn earn_points_updates_both_balances() {
let mut state = State::default();
state.earn_points(100);
assert_eq!(state.party_points, 100);
assert_eq!(state.lifetime_points_earned, 100);
state.party_points -= 30;
state.earn_points(50);
assert_eq!(state.party_points, 120);
assert_eq!(state.lifetime_points_earned, 150);
}
#[test]
fn default_state_has_commit_value_at_level_one() {
let state = State::default();
assert_eq!(state.bonus_level("commit_value"), 1);
}
#[test]
fn default_state_has_one_unlock() {
let state = State::default();
assert_eq!(state.unlocked_parties.len(), 1);
assert_eq!(state.enabled_parties.len(), 1);
}
#[test]
fn bonus_level_returns_zero_for_missing() {
let state = State::default();
assert_eq!(state.bonus_level("nonexistent"), 0);
}
#[test]
fn set_bonus_level_works() {
let mut state = State::default();
state.set_bonus_level("first_push", 3);
assert_eq!(state.bonus_level("first_push"), 3);
}
#[test]
fn points_per_commit_uses_commit_value_level() {
let mut state = State::default();
assert_eq!(state.points_per_commit(), 1);
state.set_bonus_level("commit_value", 2);
assert_eq!(state.points_per_commit(), 2);
state.set_bonus_level("commit_value", 5);
assert_eq!(state.points_per_commit(), 5);
}
#[test]
fn unlock_feature_adds_to_both_sets() {
let mut state = State::default();
let id = "exclamations";
state.unlock_party(id);
assert!(state.is_party_unlocked(id));
assert!(state.is_party_enabled(id));
}
#[test]
fn toggle_feature_works() {
let mut state = State::default();
let id = "exclamations";
state.unlock_party(id);
assert!(state.is_party_enabled(id));
state.toggle_party(id);
assert!(!state.is_party_enabled(id));
state.toggle_party(id);
assert!(state.is_party_enabled(id));
}
#[test]
fn toggle_locked_party_does_nothing() {
let mut state = State::default();
let id = "big_text";
state.toggle_party(id);
assert!(!state.is_party_enabled(id));
}
#[test]
fn test_add_and_open_pack() {
let mut state = State::default();
assert_eq!(state.pack_count(&Pack::Basic), 0);
state.add_pack(Pack::Basic);
assert_eq!(state.pack_count(&Pack::Basic), 1);
state.open_pack(Pack::Basic);
}
#[test]
fn get_packs_based_on_lifetime_points() {
let mut state = State::default();
assert_eq!(state.lifetime_packs_earned, 0);
assert_eq!(state.pack_count(&Pack::Basic), 0);
let thresholds = state.earn_points(PACK_ACCRUAL_RATE);
assert_eq!(thresholds, vec![PACK_ACCRUAL_RATE]);
assert_eq!(state.lifetime_packs_earned, 1);
assert_eq!(state.pack_count(&Pack::Basic), 1);
let thresholds = state.earn_points(5 * PACK_ACCRUAL_RATE);
assert_eq!(
thresholds,
vec![3 * PACK_ACCRUAL_RATE, 6 * PACK_ACCRUAL_RATE]
);
assert_eq!(state.lifetime_packs_earned, 3);
assert_eq!(state.pack_count(&Pack::Basic), 3);
}
}