use std::path::PathBuf;
use std::{env, fs};
pub const BOARD: usize = 5;
fn dir() -> Option<PathBuf> {
if cfg!(any(target_arch = "wasm32", target_os = "android")) {
return None;
}
let var = |k: &str| env::var_os(k).map(PathBuf::from);
let base = if cfg!(windows) {
var("APPDATA")?
} else if cfg!(target_os = "macos") {
var("HOME")?.join("Library/Application Support")
} else {
var("XDG_DATA_HOME").filter(|p| p.is_absolute()).or_else(|| Some(var("HOME")?.join(".local/share")))?
};
Some(base.join("hushjack"))
}
fn read(name: &str) -> String {
dir().and_then(|d| fs::read_to_string(d.join(name)).ok()).unwrap_or_default()
}
fn write(name: &str, text: &str) {
let Some(d) = dir() else { return };
if let Err(e) = fs::create_dir_all(&d).and_then(|_| fs::write(d.join(name), text)) {
eprintln!("hushjack: couldn't save {name}: {e}");
}
}
pub fn load_card_back() -> usize {
read("settings.txt")
.lines()
.find_map(|l| l.strip_prefix("card_back=")?.trim().parse().ok())
.unwrap_or(0)
}
pub fn save_card_back(i: usize) {
write("settings.txt", &format!("card_back={i}\n"));
}
#[derive(Clone, Debug, PartialEq)]
pub struct Entry {
pub name: String,
pub net: i64,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Scores {
pub wins: Vec<Entry>,
pub losses: Vec<Entry>,
}
impl Scores {
pub fn load() -> Self {
Self::parse(&read("scores.txt"))
}
pub fn save(&self) {
write("scores.txt", &self.to_text());
}
fn parse(text: &str) -> Self {
let mut s = Scores::default();
for line in text.lines() {
let mut f = line.split_whitespace();
let (Some(side), Some(name), Some(net)) = (f.next(), f.next(), f.next()) else { continue };
let Ok(net) = net.parse() else { continue };
let e = Entry { name: name.chars().take(3).collect(), net };
match side {
"W" if net > 0 => s.wins.push(e),
"L" if net < 0 => s.losses.push(e),
_ => {}
}
}
s.tidy();
s
}
fn to_text(&self) -> String {
let w = self.wins.iter().map(|e| format!("W {} {}\n", e.name, e.net));
let l = self.losses.iter().map(|e| format!("L {} {}\n", e.name, e.net));
w.chain(l).collect()
}
fn tidy(&mut self) {
self.wins.sort_by_key(|e| std::cmp::Reverse(e.net));
self.losses.sort_by_key(|e| e.net);
self.wins.truncate(BOARD);
self.losses.truncate(BOARD);
}
pub fn is_record(&self, net: i64) -> bool {
match net {
n if n > 0 => self.wins.len() < BOARD || self.wins.last().is_some_and(|e| n > e.net),
n if n < 0 => self.losses.len() < BOARD || self.losses.last().is_some_and(|e| n < e.net),
_ => false,
}
}
pub fn add(&mut self, name: &str, net: i64) -> Option<(bool, usize)> {
if !self.is_record(net) {
return None;
}
let win = net > 0;
let side = if win { &mut self.wins } else { &mut self.losses };
let at = side.iter().position(|e| if win { net > e.net } else { net < e.net }).unwrap_or(side.len());
side.insert(at, Entry { name: name.to_string(), net });
side.truncate(BOARD);
Some((win, at))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn names(v: &[Entry]) -> Vec<&str> {
v.iter().map(|e| e.name.as_str()).collect()
}
#[test]
fn keeps_the_best_wins_and_worst_losses() {
let mut s = Scores::default();
for (n, net) in [("AAA", 10), ("BBB", 50), ("CCC", 30), ("DDD", 20), ("EEE", 40)] {
assert!(s.add(n, net).is_some());
}
assert_eq!(names(&s.wins), ["BBB", "EEE", "CCC", "DDD", "AAA"]);
assert!(!s.is_record(5));
assert_eq!(s.add("FFF", 5), None);
assert_eq!(s.add("GGG", 45), Some((true, 1)));
assert_eq!(names(&s.wins), ["BBB", "GGG", "EEE", "CCC", "DDD"]);
assert_eq!(s.add("HHH", -30), Some((false, 0)));
assert_eq!(s.add("III", -90), Some((false, 0)));
assert_eq!(names(&s.losses), ["III", "HHH"]);
assert!(!s.is_record(0));
}
#[test]
fn a_tie_goes_below_the_older_entry() {
let mut s = Scores::default();
s.add("OLD", 20);
assert_eq!(s.add("NEW", 20), Some((true, 1)));
}
#[test]
fn text_round_trip_and_bad_lines() {
let mut s = Scores::default();
s.add("ABC", 120);
s.add("XYZ", -7);
assert_eq!(Scores::parse(&s.to_text()), s);
let odd = Scores::parse("W ABC notanumber\nnonsense\nW TOOLONG 5\nL WIN 10\n");
assert_eq!(names(&odd.wins), ["TOO"]);
assert!(odd.losses.is_empty());
}
}