fix(engine,server): safe area clamp, analytics batch, achievement save order, daily rollover, replay validation, leaderboard opt-in (#56, #60, #61, #62, #66, #68)
Build and Deploy / build-and-push (push) Successful in 3m54s

- #66: Clamp safe-area insets to 25% of window height with warn!() on excess
- #68: Move fire_flush outside per-event loop in analytics (batch flush once)
- #56: Persist progress before marking reward_granted to prevent XP loss on crash
- #60: Add DateRolloverTimer + check_date_rollover system for midnight seed refresh
- #62: Add validate_header() in replay upload with mode/draw_mode allowlists
- #61: Restore two-query leaderboard opt-in check (SELECT then UPDATE); original
       queries already in .sqlx cache; EXISTS variant would require sqlx prepare

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
funman300
2026-05-28 13:07:22 -07:00
parent 8cb4c9808e
commit 6e407a3ea7
104 changed files with 6356 additions and 3092 deletions
+5 -8
View File
@@ -72,14 +72,11 @@ mod tests {
let path = tmp_path("round_trip");
let _ = fs::remove_file(&path);
let records = vec![
AchievementRecord::locked("first_win"),
{
let mut r = AchievementRecord::locked("century");
r.unlock(Utc::now());
r
},
];
let records = vec![AchievementRecord::locked("first_win"), {
let mut r = AchievementRecord::locked("century");
r.unlock(Utc::now());
r
}];
save_achievements_to(&path, &records).expect("save");
let loaded = load_achievements_from(&path);
assert_eq!(loaded.len(), 2);
+23 -20
View File
@@ -14,8 +14,8 @@
///
/// Only compiled and linked on `target_os = "android"`.
use jni::{
objects::{JByteArray, JObject, JObjectArray, JValue, JValueOwned},
JNIEnv, JavaVM,
objects::{JByteArray, JObject, JObjectArray, JValue, JValueOwned},
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@@ -100,8 +100,7 @@ fn load_or_create_key<'local>(env: &mut JNIEnv<'local>) -> jni::errors::Result<J
}
// No key yet — generate AES-256 with GCM block mode.
let builder_class =
env.find_class("android/security/keystore/KeyGenParameterSpec$Builder")?;
let builder_class = env.find_class("android/security/keystore/KeyGenParameterSpec$Builder")?;
let alias2 = JValueOwned::from(env.new_string(KEY_ALIAS)?);
// PURPOSE_ENCRYPT | PURPOSE_DECRYPT = 1 | 2 = 3
let purpose = JValueOwned::Int(3);
@@ -252,11 +251,7 @@ fn decrypt_gcm(
let tag_len = JValueOwned::Int(128);
let iv_arr = env.byte_array_from_slice(iv)?;
let iv_val = JValueOwned::Object(iv_arr.into());
let spec = env.new_object(
&spec_class,
"(I[B)V",
&[tag_len.borrow(), iv_val.borrow()],
)?;
let spec = env.new_object(&spec_class, "(I[B)V", &[tag_len.borrow(), iv_val.borrow()])?;
// cipher.init(Cipher.DECRYPT_MODE=2, key, spec)
let mode = JValueOwned::Int(2);
@@ -284,8 +279,7 @@ fn decrypt_gcm(
// ---------------------------------------------------------------------------
fn token_file_path() -> Option<PathBuf> {
crate::platform::data_dir()
.map(|d| d.join(crate::APP_DIR_NAME).join("auth_tokens.bin"))
crate::platform::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join("auth_tokens.bin"))
}
/// Path where the token file lived before the APP_DIR_NAME subdirectory was
@@ -302,8 +296,8 @@ fn read_file_bytes_from(path: &PathBuf) -> Result<Vec<u8>, TokenError> {
}
fn write_file_bytes(data: &[u8]) -> Result<(), TokenError> {
let path = token_file_path()
.ok_or_else(|| TokenError::KeychainUnavailable("no data dir".into()))?;
let path =
token_file_path().ok_or_else(|| TokenError::KeychainUnavailable("no data dir".into()))?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| TokenError::Keyring(format!("create dir: {e}")))?;
@@ -328,8 +322,8 @@ fn write_file_bytes(data: &[u8]) -> Result<(), TokenError> {
/// - Delete the legacy file (best-effort; leave it if removal fails).
/// 3. If neither file exists, return an empty map.
fn read_map() -> Result<HashMap<String, TokenBlob>, TokenError> {
let new_path = token_file_path()
.ok_or_else(|| TokenError::KeychainUnavailable("no data dir".into()))?;
let new_path =
token_file_path().ok_or_else(|| TokenError::KeychainUnavailable("no data dir".into()))?;
let legacy_path = legacy_token_file_path();
// --- 1. New path exists ---
@@ -339,7 +333,9 @@ fn read_map() -> Result<HashMap<String, TokenBlob>, TokenError> {
other => other,
})?;
if data.len() < 12 {
return Err(TokenError::Keyring("auth_tokens.bin corrupt (too short)".into()));
return Err(TokenError::Keyring(
"auth_tokens.bin corrupt (too short)".into(),
));
}
let plaintext = with_jvm(|env| {
let key = load_or_create_key(env)?;
@@ -355,7 +351,9 @@ fn read_map() -> Result<HashMap<String, TokenBlob>, TokenError> {
map.insert(blob.username.clone(), blob);
return Ok(map);
}
return Err(TokenError::Keyring("auth_tokens.bin unrecognised format".into()));
return Err(TokenError::Keyring(
"auth_tokens.bin unrecognised format".into(),
));
}
// --- 2. Legacy path migration ---
@@ -390,8 +388,8 @@ fn read_map() -> Result<HashMap<String, TokenBlob>, TokenError> {
/// Serialise and encrypt a map, then write it atomically.
fn write_map_inner(map: &HashMap<String, TokenBlob>) -> Result<(), TokenError> {
let plaintext = serde_json::to_vec(map)
.map_err(|e| TokenError::Keyring(format!("JSON encode: {e}")))?;
let plaintext =
serde_json::to_vec(map).map_err(|e| TokenError::Keyring(format!("JSON encode: {e}")))?;
let encrypted = with_jvm(|env| {
let key = load_or_create_key(env)?;
encrypt_gcm(env, &key, &plaintext)
@@ -500,8 +498,13 @@ pub fn delete_tokens(username: &str) -> Result<(), TokenError> {
.v()?;
let alias = JValueOwned::from(env.new_string(KEY_ALIAS)?);
env.call_method(&ks, "deleteEntry", "(Ljava/lang/String;)V", &[alias.borrow()])?
.v()
env.call_method(
&ks,
"deleteEntry",
"(Ljava/lang/String;)V",
&[alias.borrow()],
)?
.v()
})
} else {
// Other users still exist — just rewrite the map without this user.
+5 -1
View File
@@ -294,7 +294,11 @@ mod tests {
sorted.sort_unstable();
let before = sorted.len();
sorted.dedup();
assert_eq!(sorted.len(), before, "duplicate seeds found across difficulty tiers");
assert_eq!(
sorted.len(),
before,
"duplicate seeds found across difficulty tiers"
);
}
#[test]
+24 -24
View File
@@ -104,43 +104,43 @@ pub use stats::{StatsExt, StatsSnapshot};
pub mod storage;
pub use storage::{
cleanup_orphaned_tmp_files, delete_game_state_at, delete_time_attack_session_at,
game_state_file_path, load_game_state_from, load_stats, load_stats_from,
load_time_attack_session_from, load_time_attack_session_from_at, save_game_state_to,
save_stats, save_stats_to, save_time_attack_session_to, stats_file_path,
time_attack_session_path, time_attack_session_with_now, TimeAttackSession,
TimeAttackSession, cleanup_orphaned_tmp_files, delete_game_state_at,
delete_time_attack_session_at, game_state_file_path, load_game_state_from, load_stats,
load_stats_from, load_time_attack_session_from, load_time_attack_session_from_at,
save_game_state_to, save_stats, save_stats_to, save_time_attack_session_to, stats_file_path,
time_attack_session_path, time_attack_session_with_now,
};
pub mod achievements;
pub use achievements::{
achievements_file_path, load_achievements_from, save_achievements_to, AchievementRecord,
AchievementRecord, achievements_file_path, load_achievements_from, save_achievements_to,
};
pub mod progress;
pub use progress::{
daily_seed_for, level_for_xp, load_progress_from, progress_file_path, save_progress_to,
xp_for_win, PlayerProgress,
PlayerProgress, daily_seed_for, level_for_xp, load_progress_from, progress_file_path,
save_progress_to, xp_for_win,
};
pub mod weekly;
pub use weekly::{
current_iso_week_key, weekly_goal_by_id, WeeklyGoalContext, WeeklyGoalDef, WeeklyGoalKind,
WEEKLY_GOALS, WEEKLY_GOAL_XP,
WEEKLY_GOAL_XP, WEEKLY_GOALS, WeeklyGoalContext, WeeklyGoalDef, WeeklyGoalKind,
current_iso_week_key, weekly_goal_by_id,
};
pub mod challenge;
pub use challenge::{challenge_count, challenge_seed_for, CHALLENGE_SEEDS};
pub use challenge::{CHALLENGE_SEEDS, challenge_count, challenge_seed_for};
pub mod difficulty_seeds;
pub use difficulty_seeds::{seeds_for, DifficultySeeds};
pub use difficulty_seeds::{DifficultySeeds, seeds_for};
pub mod settings;
pub use settings::{
load_settings_from, save_settings_to, settings_file_path, AnimSpeed, Settings, SyncBackend,
Theme, WindowGeometry, REPLAY_MOVE_INTERVAL_MAX_SECS, REPLAY_MOVE_INTERVAL_MIN_SECS,
REPLAY_MOVE_INTERVAL_STEP_SECS, SOLVER_DEAL_RETRY_CAP, TIME_BONUS_MULTIPLIER_MAX,
TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_MAX_SECS,
TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_STEP_SECS,
AnimSpeed, REPLAY_MOVE_INTERVAL_MAX_SECS, REPLAY_MOVE_INTERVAL_MIN_SECS,
REPLAY_MOVE_INTERVAL_STEP_SECS, SOLVER_DEAL_RETRY_CAP, Settings, SyncBackend,
TIME_BONUS_MULTIPLIER_MAX, TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_STEP,
TOOLTIP_DELAY_MAX_SECS, TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_STEP_SECS, Theme, WindowGeometry,
load_settings_from, save_settings_to, settings_file_path,
};
#[cfg(target_os = "android")]
@@ -148,20 +148,20 @@ mod android_keystore;
pub mod auth_tokens;
pub use auth_tokens::{
delete_tokens, load_access_token, load_refresh_token, store_tokens, TokenError,
TokenError, delete_tokens, load_access_token, load_refresh_token, store_tokens,
};
pub mod sync_client;
pub use sync_client::{provider_for_backend, LocalOnlyProvider, SolitaireServerClient};
pub use sync_client::{LocalOnlyProvider, SolitaireServerClient, provider_for_backend};
pub mod replay;
pub use replay::{
REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION, Replay,
ReplayHistory, ReplayMove, append_replay_to_history, load_replay_history_from,
migrate_legacy_latest_replay, replay_history_path, save_replay_history_to,
};
#[allow(deprecated)]
pub use replay::{latest_replay_path, load_latest_replay_from, save_latest_replay_to};
pub use replay::{
append_replay_to_history, load_replay_history_from, migrate_legacy_latest_replay,
replay_history_path, save_replay_history_to, Replay, ReplayHistory, ReplayMove,
REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION,
};
pub mod matomo_client;
pub use matomo_client::MatomoClient;
+1 -7
View File
@@ -47,13 +47,7 @@ impl MatomoClient {
///
/// When the buffer exceeds 100 events the oldest 50 are dropped to
/// prevent unbounded memory growth during extended offline play.
pub fn event(
&self,
category: &str,
action: &str,
name: Option<&str>,
value: Option<f64>,
) {
pub fn event(&self, category: &str, action: &str, name: Option<&str>, value: Option<f64>) {
let Ok(mut guard) = self.pending.lock() else {
return;
};
+4 -1
View File
@@ -87,6 +87,9 @@ mod tests {
#[test]
fn data_dir_returns_sandbox_path_on_android() {
let dir = data_dir().expect("android must report a data dir");
assert_eq!(dir, PathBuf::from("/data/data/com.ferrousapp.solitaire/files"));
assert_eq!(
dir,
PathBuf::from("/data/data/com.ferrousapp.solitaire/files")
);
}
}
+5 -2
View File
@@ -11,8 +11,8 @@ use std::path::{Path, PathBuf};
use chrono::{Datelike, NaiveDate};
pub use solitaire_sync::progress::level_for_xp;
pub use solitaire_sync::PlayerProgress;
pub use solitaire_sync::progress::level_for_xp;
const FILE_NAME: &str = "progress.json";
@@ -147,7 +147,10 @@ mod tests {
#[test]
fn add_xp_saturates_on_overflow() {
let mut p = PlayerProgress { total_xp: u64::MAX - 5, ..Default::default() };
let mut p = PlayerProgress {
total_xp: u64::MAX - 5,
..Default::default()
};
p.add_xp(100);
assert_eq!(p.total_xp, u64::MAX);
}
+35 -25
View File
@@ -293,11 +293,9 @@ pub fn replay_history_path() -> Option<PathBuf> {
///
/// Overwrites any existing replay — only the most recent winning replay
/// is retained on disk.
#[deprecated(
note = "single-slot replay storage replaced by the rolling history; \
#[deprecated(note = "single-slot replay storage replaced by the rolling history; \
use append_replay_to_history instead. Kept for the one-shot \
legacy migration."
)]
legacy migration.")]
pub fn save_latest_replay_to(path: &Path, replay: &Replay) -> io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
@@ -317,11 +315,9 @@ pub fn save_latest_replay_to(path: &Path, replay: &Replay) -> io::Result<()> {
/// "No replay recorded yet" caption rather than a half-loaded broken
/// replay. Bumping [`REPLAY_SCHEMA_VERSION`] therefore invalidates every
/// older save without further migration code.
#[deprecated(
note = "single-slot replay storage replaced by the rolling history; \
#[deprecated(note = "single-slot replay storage replaced by the rolling history; \
use load_replay_history_from instead. Kept for the one-shot \
legacy migration."
)]
legacy migration.")]
pub fn load_latest_replay_from(path: &Path) -> Option<Replay> {
let data = fs::read(path).ok()?;
let replay: Replay = serde_json::from_slice(&data).ok()?;
@@ -383,10 +379,7 @@ pub fn load_replay_history_from(path: &Path) -> Option<ReplayHistory> {
/// [`ReplayHistory`] is the exact value written to disk so callers can
/// update an in-memory mirror (e.g. the Stats overlay's
/// `ReplayHistoryResource`) without a follow-up `load`.
pub fn append_replay_to_history(
path: &Path,
replay: Replay,
) -> io::Result<ReplayHistory> {
pub fn append_replay_to_history(path: &Path, replay: Replay) -> io::Result<ReplayHistory> {
let mut history = load_replay_history_from(path).unwrap_or_default();
// Most recent first. Reserve the front slot; pop the oldest if we
// exceed the cap so the file never grows unbounded.
@@ -438,9 +431,7 @@ pub fn migrate_legacy_latest_replay(latest_path: &Path, history_path: &Path) {
// Migration failure is non-fatal: on the next launch we'll just
// try again. We log to stderr rather than panic so headless
// tests stay quiet.
eprintln!(
"replay: failed to migrate legacy latest_replay.json into rolling history: {e}",
);
eprintln!("replay: failed to migrate legacy latest_replay.json into rolling history: {e}",);
}
}
@@ -623,8 +614,8 @@ mod tests {
let mut last_returned = ReplayHistory::default();
for i in 0..10 {
last_returned = append_replay_to_history(&path, replay_with_id(i))
.expect("append must succeed");
last_returned =
append_replay_to_history(&path, replay_with_id(i)).expect("append must succeed");
}
assert_eq!(
@@ -634,7 +625,11 @@ mod tests {
);
// The most recent ten pushes were ids 0..=9; ids 9, 8, ..., 2
// survive (newest first), ids 0 and 1 aged out.
let ids: Vec<i32> = last_returned.replays.iter().map(|r| r.final_score).collect();
let ids: Vec<i32> = last_returned
.replays
.iter()
.map(|r| r.final_score)
.collect();
assert_eq!(
ids,
vec![9, 8, 7, 6, 5, 4, 3, 2],
@@ -683,18 +678,30 @@ mod tests {
// Seed the legacy file with a real replay.
let legacy_replay = sample_replay();
save_latest_replay_to(&latest, &legacy_replay).expect("seed legacy");
assert!(!history.exists(), "history file must not exist pre-migration");
assert!(
!history.exists(),
"history file must not exist pre-migration"
);
migrate_legacy_latest_replay(&latest, &history);
assert!(history.exists(), "migration must create the history file");
let loaded = load_replay_history_from(&history)
.expect("post-migration history must load");
assert_eq!(loaded.replays.len(), 1, "history must hold exactly the legacy entry");
assert_eq!(loaded.replays[0], legacy_replay, "entry must equal the legacy replay");
let loaded = load_replay_history_from(&history).expect("post-migration history must load");
assert_eq!(
loaded.replays.len(),
1,
"history must hold exactly the legacy entry"
);
assert_eq!(
loaded.replays[0], legacy_replay,
"entry must equal the legacy replay"
);
// Legacy file is intentionally retained for one release as a
// safety net — see `migrate_legacy_latest_replay` doc comment.
assert!(latest.exists(), "legacy file must NOT be deleted by migration");
assert!(
latest.exists(),
"legacy file must NOT be deleted by migration"
);
let _ = fs::remove_file(&latest);
let _ = fs::remove_file(&history);
@@ -720,7 +727,10 @@ mod tests {
migrate_legacy_latest_replay(&latest, &history);
let loaded = load_replay_history_from(&history).expect("load");
assert_eq!(loaded, pre_existing, "existing history must not be overwritten");
assert_eq!(
loaded, pre_existing,
"existing history must not be overwritten"
);
let _ = fs::remove_file(&latest);
let _ = fs::remove_file(&history);
+33 -19
View File
@@ -60,7 +60,6 @@ pub enum SyncBackend {
avatar_url: Option<String>,
// JWT tokens are stored in the OS keychain — not here.
},
}
/// Persisted window size (in logical pixels) and screen position
@@ -447,8 +446,8 @@ impl Settings {
/// to `[TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_MAX_SECS]`. Returns the
/// new value.
pub fn adjust_tooltip_delay(&mut self, delta: f32) -> f32 {
self.tooltip_delay_secs = (self.tooltip_delay_secs + delta)
.clamp(TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_MAX_SECS);
self.tooltip_delay_secs =
(self.tooltip_delay_secs + delta).clamp(TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_MAX_SECS);
self.tooltip_delay_secs
}
@@ -522,7 +521,10 @@ mod tests {
#[test]
fn adjust_sfx_volume_clamps() {
let mut s = Settings { sfx_volume: 0.5, ..Default::default() };
let mut s = Settings {
sfx_volume: 0.5,
..Default::default()
};
assert!((s.adjust_sfx_volume(0.3) - 0.8).abs() < 1e-6);
assert!((s.adjust_sfx_volume(0.5) - 1.0).abs() < 1e-6);
assert!((s.adjust_sfx_volume(-2.0) - 0.0).abs() < 1e-6);
@@ -531,7 +533,10 @@ mod tests {
#[test]
fn adjust_music_volume_clamps() {
let mut s = Settings { music_volume: 0.5, ..Default::default() };
let mut s = Settings {
music_volume: 0.5,
..Default::default()
};
assert!((s.adjust_music_volume(0.3) - 0.8).abs() < 1e-6);
assert!((s.adjust_music_volume(0.5) - 1.0).abs() < 1e-6);
assert!((s.adjust_music_volume(-2.0) - 0.0).abs() < 1e-6);
@@ -570,7 +575,10 @@ mod tests {
#[test]
fn adjust_tooltip_delay_clamps_to_range() {
let mut s = Settings { tooltip_delay_secs: 0.5, ..Default::default() };
let mut s = Settings {
tooltip_delay_secs: 0.5,
..Default::default()
};
// Step up to 0.6.
assert!((s.adjust_tooltip_delay(0.1) - 0.6).abs() < 1e-6);
// Big positive jump clamps to TOOLTIP_DELAY_MAX_SECS.
@@ -583,21 +591,23 @@ mod tests {
#[test]
fn adjust_time_bonus_multiplier_clamps_and_rounds() {
let mut s = Settings { time_bonus_multiplier: 1.0, ..Default::default() };
let mut s = Settings {
time_bonus_multiplier: 1.0,
..Default::default()
};
// Step up to 1.1.
assert!((s.adjust_time_bonus_multiplier(0.1) - 1.1).abs() < 1e-6);
// Big positive jump clamps to TIME_BONUS_MULTIPLIER_MAX.
assert!(
(s.adjust_time_bonus_multiplier(99.0) - TIME_BONUS_MULTIPLIER_MAX).abs() < 1e-6
);
assert!((s.adjust_time_bonus_multiplier(99.0) - TIME_BONUS_MULTIPLIER_MAX).abs() < 1e-6);
// Big negative jump clamps to TIME_BONUS_MULTIPLIER_MIN.
assert!(
(s.adjust_time_bonus_multiplier(-99.0) - TIME_BONUS_MULTIPLIER_MIN).abs() < 1e-6
);
assert!((s.adjust_time_bonus_multiplier(-99.0) - TIME_BONUS_MULTIPLIER_MIN).abs() < 1e-6);
assert_eq!(s.time_bonus_multiplier, 0.0);
// Repeated incremental adds must not drift past the 0.1 grid.
let mut s2 = Settings { time_bonus_multiplier: 0.0, ..Default::default() };
let mut s2 = Settings {
time_bonus_multiplier: 0.0,
..Default::default()
};
for _ in 0..10 {
s2.adjust_time_bonus_multiplier(0.1);
}
@@ -611,20 +621,24 @@ mod tests {
#[test]
fn adjust_replay_move_interval_clamps_and_rounds() {
let mut s = Settings { replay_move_interval_secs: 0.45, ..Default::default() };
let mut s = Settings {
replay_move_interval_secs: 0.45,
..Default::default()
};
// Step down to 0.40.
assert!((s.adjust_replay_move_interval(-0.05) - 0.40).abs() < 1e-6);
// Big positive jump clamps to MAX.
assert!(
(s.adjust_replay_move_interval(99.0) - REPLAY_MOVE_INTERVAL_MAX_SECS).abs() < 1e-6
);
assert!((s.adjust_replay_move_interval(99.0) - REPLAY_MOVE_INTERVAL_MAX_SECS).abs() < 1e-6);
// Big negative jump clamps to MIN.
assert!(
(s.adjust_replay_move_interval(-99.0) - REPLAY_MOVE_INTERVAL_MIN_SECS).abs() < 1e-6
);
// Repeated 0.05 steps must not drift past the 0.05 grid.
let mut s2 = Settings { replay_move_interval_secs: 0.10, ..Default::default() };
let mut s2 = Settings {
replay_move_interval_secs: 0.10,
..Default::default()
};
for _ in 0..6 {
s2.adjust_replay_move_interval(0.05);
}
+13 -3
View File
@@ -231,14 +231,24 @@ mod tests {
// Win once — current becomes 1, best must remain 5.
s.update_on_win(100, 60, &DrawMode::DrawOne);
assert_eq!(s.win_streak_current, 1);
assert_eq!(s.win_streak_best, 5, "best must not drop to match shorter streak");
assert_eq!(
s.win_streak_best, 5,
"best must not drop to match shorter streak"
);
}
#[test]
fn lifetime_score_saturates_at_u64_max() {
let mut s = StatsSnapshot { lifetime_score: u64::MAX - 100, ..Default::default() };
let mut s = StatsSnapshot {
lifetime_score: u64::MAX - 100,
..Default::default()
};
s.update_on_win(200, 60, &DrawMode::DrawOne);
assert_eq!(s.lifetime_score, u64::MAX, "lifetime_score must saturate, not overflow");
assert_eq!(
s.lifetime_score,
u64::MAX,
"lifetime_score must saturate, not overflow"
);
}
// -----------------------------------------------------------------------
+16 -12
View File
@@ -9,7 +9,7 @@ use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use solitaire_core::game_state::{GameState, GAME_STATE_SCHEMA_VERSION};
use solitaire_core::game_state::{GAME_STATE_SCHEMA_VERSION, GameState};
use crate::stats::StatsSnapshot;
@@ -57,9 +57,8 @@ pub fn load_stats() -> StatsSnapshot {
/// Save stats to the platform default path. Returns an error if the platform
/// data dir is unavailable or the write fails.
pub fn save_stats(stats: &StatsSnapshot) -> io::Result<()> {
let path = stats_file_path().ok_or_else(|| {
io::Error::new(io::ErrorKind::NotFound, "platform data dir unavailable")
})?;
let path = stats_file_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "platform data dir unavailable"))?;
save_stats_to(&path, stats)
}
@@ -89,11 +88,7 @@ pub fn load_game_state_from(path: &Path) -> Option<GameState> {
if gs.schema_version != GAME_STATE_SCHEMA_VERSION {
return None;
}
if gs.is_won {
None
} else {
Some(gs)
}
if gs.is_won { None } else { Some(gs) }
}
/// Save an in-progress `GameState` atomically. Skips the write if `gs.is_won`
@@ -180,7 +175,10 @@ pub struct TimeAttackSession {
/// Returns the platform-specific path to `time_attack_session.json`, or
/// `None` if `crate::data_dir()` is unavailable.
pub fn time_attack_session_path() -> Option<PathBuf> {
crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(TIME_ATTACK_SESSION_FILE_NAME))
crate::data_dir().map(|d| {
d.join(crate::APP_DIR_NAME)
.join(TIME_ATTACK_SESSION_FILE_NAME)
})
}
/// Save a Time Attack session atomically. Mirrors `save_game_state_to`'s
@@ -422,7 +420,10 @@ mod tests {
let mut gs = GameState::new(99, DrawMode::DrawOne);
gs.is_won = true;
save_game_state_to(&path, &gs).expect("save should be no-op, not error");
assert!(!path.exists(), "should not have written a file for a won game");
assert!(
!path.exists(),
"should not have written a file for a won game"
);
}
#[test]
@@ -556,7 +557,10 @@ mod tests {
loaded.remaining_secs,
);
assert_eq!(loaded.wins, 3, "wins must round-trip");
assert_eq!(loaded.saved_at_unix_secs, saved_at, "timestamp must round-trip");
assert_eq!(
loaded.saved_at_unix_secs, saved_at,
"timestamp must round-trip"
);
let _ = fs::remove_file(&path);
}
+46 -31
View File
@@ -15,10 +15,10 @@ use async_trait::async_trait;
use solitaire_sync::{ChallengeGoal, LeaderboardEntry, SyncPayload, SyncResponse};
use crate::{
SyncError, SyncProvider,
auth_tokens::{load_access_token, load_refresh_token, store_tokens},
replay::Replay,
settings::SyncBackend,
SyncError, SyncProvider,
};
// ---------------------------------------------------------------------------
@@ -125,10 +125,7 @@ impl SolitaireServerClient {
async fn extract_auth_tokens(resp: reqwest::Response) -> Result<(String, String), SyncError> {
let status = resp.status();
if !status.is_success() {
let body: serde_json::Value = resp
.json()
.await
.unwrap_or(serde_json::json!({}));
let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::json!({}));
let msg = body["error"]
.as_str()
.or_else(|| body["message"].as_str())
@@ -166,8 +163,8 @@ impl SolitaireServerClient {
/// new refresh token that replaces the old one. Both tokens are persisted
/// to the OS keychain on success.
async fn refresh_token(&self) -> Result<(), SyncError> {
let old_refresh = load_refresh_token(&self.username)
.map_err(|e| SyncError::Auth(e.to_string()))?;
let old_refresh =
load_refresh_token(&self.username).map_err(|e| SyncError::Auth(e.to_string()))?;
let resp = self
.client
@@ -186,9 +183,9 @@ impl SolitaireServerClient {
.await
.map_err(|e| SyncError::Serialization(e.to_string()))?;
let new_access = body["access_token"]
.as_str()
.ok_or_else(|| SyncError::Serialization("missing access_token in refresh response".into()))?;
let new_access = body["access_token"].as_str().ok_or_else(|| {
SyncError::Serialization("missing access_token in refresh response".into())
})?;
// Server rotates refresh tokens — store the new one.
// Fall back to the old token if the field is absent (pre-rotation server).
@@ -368,13 +365,19 @@ impl SyncProvider for SolitaireServerClient {
.await
.map_err(|e| SyncError::Network(e.to_string()))?;
if !resp.status().is_success() {
return Err(SyncError::Auth(format!("opt-out failed: {}", resp.status())));
return Err(SyncError::Auth(format!(
"opt-out failed: {}",
resp.status()
)));
}
return Ok(());
}
if !resp.status().is_success() {
return Err(SyncError::Auth(format!("opt-out failed: {}", resp.status())));
return Err(SyncError::Auth(format!(
"opt-out failed: {}",
resp.status()
)));
}
Ok(())
}
@@ -402,13 +405,19 @@ impl SyncProvider for SolitaireServerClient {
.await
.map_err(|e| SyncError::Network(e.to_string()))?;
if !resp.status().is_success() {
return Err(SyncError::Auth(format!("delete account failed: {}", resp.status())));
return Err(SyncError::Auth(format!(
"delete account failed: {}",
resp.status()
)));
}
return Ok(());
}
if !resp.status().is_success() {
return Err(SyncError::Auth(format!("delete account failed: {}", resp.status())));
return Err(SyncError::Auth(format!(
"delete account failed: {}",
resp.status()
)));
}
Ok(())
}
@@ -480,27 +489,26 @@ impl SyncProvider for SolitaireServerClient {
impl SolitaireServerClient {
/// Pulled out of `push_replay` so both the first attempt and the
/// post-401-retry attempt go through the same parse path.
async fn share_url_from_response(
&self,
resp: reqwest::Response,
) -> Result<String, SyncError> {
async fn share_url_from_response(&self, resp: reqwest::Response) -> Result<String, SyncError> {
let status = resp.status();
if !status.is_success() {
return Err(if status == reqwest::StatusCode::UNAUTHORIZED
|| status == reqwest::StatusCode::FORBIDDEN
{
SyncError::Auth(format!("server returned {status}"))
} else {
SyncError::Network(format!("server returned {status}"))
});
return Err(
if status == reqwest::StatusCode::UNAUTHORIZED
|| status == reqwest::StatusCode::FORBIDDEN
{
SyncError::Auth(format!("server returned {status}"))
} else {
SyncError::Network(format!("server returned {status}"))
},
);
}
let body: serde_json::Value = resp
.json()
.await
.map_err(|e| SyncError::Serialization(e.to_string()))?;
let id = body["id"].as_str().ok_or_else(|| {
SyncError::Serialization("upload response missing `id`".into())
})?;
let id = body["id"]
.as_str()
.ok_or_else(|| SyncError::Serialization("upload response missing `id`".into()))?;
Ok(format!("{}/replays/{}", self.base_url, id))
}
@@ -540,7 +548,10 @@ impl SolitaireServerClient {
/// Like [`fetch_me`] but uses an explicit token instead of reading from the
/// OS keychain. Useful immediately after login/register when the token has
/// not yet been persisted.
pub async fn fetch_me_with_token(&self, token: &str) -> Result<(String, Option<String>), SyncError> {
pub async fn fetch_me_with_token(
&self,
token: &str,
) -> Result<(String, Option<String>), SyncError> {
let url = format!("{}/api/me", self.base_url);
let resp = self
.client
@@ -552,7 +563,9 @@ impl SolitaireServerClient {
Self::extract_me_body(resp).await
}
async fn extract_me_body(resp: reqwest::Response) -> Result<(String, Option<String>), SyncError> {
async fn extract_me_body(
resp: reqwest::Response,
) -> Result<(String, Option<String>), SyncError> {
let status = resp.status();
if !status.is_success() {
return Err(SyncError::Network(format!("GET /api/me returned {status}")));
@@ -595,7 +608,9 @@ async fn extract_pull_body(resp: reqwest::Response) -> Result<SyncPayload, SyncE
}
/// Deserialize a leaderboard response body as `Vec<LeaderboardEntry>`.
async fn extract_leaderboard_body(resp: reqwest::Response) -> Result<Vec<LeaderboardEntry>, SyncError> {
async fn extract_leaderboard_body(
resp: reqwest::Response,
) -> Result<Vec<LeaderboardEntry>, SyncError> {
let status = resp.status();
if status.is_success() {
resp.json()