Compare commits

...

7 Commits

Author SHA1 Message Date
funman300 a2f02e1cbc ci(argocd): watch deploy branch for kustomization updates
Android Release / build-apk (push) Successful in 4m50s
targetRevision changed from master to deploy so Argo CD tracks the
image-tag commits the CI bot writes there, not the source branch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 16:58:42 -07:00
Gitea CI 8426d89856 chore(deploy): bump image to da601beb [skip ci] 2026-05-19 23:58:25 +00:00
funman300 ecab227b8d ci(deploy): push kustomization updates to deploy branch, not master
Build and Deploy / build-and-push (push) Successful in 21s
The CI bot was committing image-tag bumps back to master after every
Docker build, which forced a `git pull --rebase` before every developer
push. Moving the kustomization commit to a dedicated `deploy` branch
keeps master clean — the build bot no longer diverges it.

Argo CD / Flux should now watch the `deploy` branch (targetRevision:
deploy) instead of master.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 16:57:20 -07:00
funman300 da601bebd6 fix(engine,wasm,web): detect no-legal-moves correctly and surface banner
Build and Deploy / build-and-push (push) Successful in 4m24s
Engine: replace broken has_legal_moves loop (which checked buried
mid-column cards without sequence validation) with a delegation to
possible_instructions(), mirroring the hint system's logic exactly.

WASM: add has_moves: bool to GameSnapshot, computed in snap() using the
same stock/waste/possible_instructions check so the web client gets the
flag in every state update at no extra round-trip cost.

Web: show a non-blocking no-moves banner (slide-up toast) with Undo and
New Game actions when has_moves is false and the game is not won. Banner
hides automatically once a move restores legal play (e.g. after undo).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 16:54:01 -07:00
Gitea CI a2dd8d220c chore(deploy): bump image to d5d869a6 [skip ci] 2026-05-19 23:31:16 +00:00
funman300 d5d869a6c8 fix(multi): resolve 16 bugs from comprehensive rules and code review
Build and Deploy / build-and-push (push) Successful in 4m12s
Core (solitaire_core):
- fix(core): auto-complete now requires waste empty to prevent deadlock
- fix(core): reject multi-card moves from waste pile (Klondike rule)
- fix(core): reject foundation-to-foundation moves (score farming exploit)
- fix(core): undo restores score from snapshot baseline, not live score
- feat(scoring): add +5 flip bonus when face-down tableau card is exposed
- feat(scoring): add recycle penalty (Draw-1: -100/pass, Draw-3: -20/pass)

Engine (solitaire_engine):
- fix(engine): remove TokioRuntimeResource::default() panic; degrade gracefully
- fix(engine): add ModalScrim guard to handle_new_game spawn site
- fix(engine): add ModalScrim guard to spawn_restore_prompt spawn site
- fix(engine): add ModalScrim guard to check_no_moves spawn site

Server / Web (solitaire_server):
- fix(web): correct draw_mode casing in replay submission (DrawOne/DrawThree)
- fix(web): correct mode casing in replay submission (Classic) for leaderboard
- fix(web): trim recorded_at to YYYY-MM-DD for NaiveDate deserialization
- fix(server): move /avatars route outside auth middleware (was always 401)

Data / Sync (solitaire_data, solitaire_sync):
- fix(data): namespace Android token file under APP_DIR_NAME with migration
- fix(data): Android token store now multi-user (HashMap); no silent overwrite
- fix(sync): draw_one_wins + draw_three_wins invariant preserved after merge

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 16:27:09 -07:00
Gitea CI 42898c0b3f chore(deploy): bump image to f6e7de10 [skip ci] 2026-05-19 22:53:25 +00:00
16 changed files with 641 additions and 189 deletions
+9 -11
View File
@@ -60,19 +60,17 @@ jobs:
curl -sL https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize%2Fv5.4.3/kustomize_v5.4.3_linux_amd64.tar.gz | tar xz
sudo mv kustomize /usr/local/bin/kustomize
- name: Pin image tag in deploy manifests
run: |
cd deploy
kustomize edit set image solitaire-server=${{ env.IMAGE }}:${{ steps.meta.outputs.sha }}
- name: Commit and push updated kustomization
- name: Pin image tag and push to deploy branch
run: |
git config user.email "ci@gitea.local"
git config user.name "Gitea CI"
# Switch to the deploy branch, creating it from the current HEAD if absent.
git fetch origin deploy 2>/dev/null && git checkout deploy || git checkout -b deploy
# Update the pinned image tag.
cd deploy
kustomize edit set image solitaire-server=${{ env.IMAGE }}:${{ steps.meta.outputs.sha }}
cd ..
git add deploy/kustomization.yaml
git diff --cached --quiet && exit 0 # nothing to commit — skip push
git diff --cached --quiet && exit 0
git commit -m "chore(deploy): bump image to ${{ steps.meta.outputs.sha }} [skip ci]"
for i in 1 2 3; do
git pull --rebase origin master && git push && break
sleep 5
done
git push origin deploy
+1 -1
View File
@@ -7,7 +7,7 @@ spec:
project: default
source:
repoURL: https://git.aleshym.co/funman300/Ferrous-Solitaire.git
targetRevision: master
targetRevision: deploy
path: deploy
destination:
server: https://kubernetes.default.svc
+1 -1
View File
@@ -20,4 +20,4 @@ resources:
images:
- name: solitaire-server
newName: git.aleshym.co/funman300/solitaire-server
newTag: 90eb5fd2
newTag: da601beb
+214 -16
View File
@@ -5,7 +5,7 @@ use crate::deck::{deal_klondike, Deck};
use crate::error::MoveError;
use crate::pile::{Pile, PileType};
use crate::rules::{can_place_on_foundation, can_place_on_tableau, is_valid_tableau_sequence};
use crate::scoring::{compute_time_bonus as scoring_time_bonus, score_move, score_undo as scoring_undo};
use crate::scoring::{compute_time_bonus as scoring_time_bonus, score_flip, score_move, score_recycle, score_undo as scoring_undo};
const MAX_UNDO_STACK: usize = 64;
@@ -247,6 +247,13 @@ impl GameState {
stock.cards.push(card);
}
self.recycle_count = self.recycle_count.saturating_add(1);
if self.mode != GameMode::Zen {
let penalty = score_recycle(
self.recycle_count,
self.draw_mode == DrawMode::DrawThree,
);
self.score = (self.score + penalty).max(0);
}
self.move_count = self.move_count.saturating_add(1);
return Ok(());
}
@@ -308,6 +315,11 @@ impl GameState {
match &to {
PileType::Foundation(_) => {
if matches!(&from, PileType::Foundation(_)) {
return Err(MoveError::RuleViolation(
"cannot move between foundation slots".into(),
));
}
if count != 1 {
return Err(MoveError::RuleViolation(
"only one card can move to foundation at a time".into(),
@@ -331,6 +343,11 @@ impl GameState {
));
}
}
if matches!(&from, PileType::Waste) && count != 1 {
return Err(MoveError::RuleViolation(
"only the top waste card may be moved".into(),
));
}
let dest = self.piles.get(&to).ok_or(MoveError::InvalidDestination)?;
if !can_place_on_tableau(&bottom_card, dest) {
return Err(MoveError::RuleViolation("invalid tableau placement".into()));
@@ -367,7 +384,8 @@ impl GameState {
.cards
.split_off(move_start);
// Flip the newly exposed top card of the source pile
// Flip the newly exposed top card of the source pile; award +5 per Windows scoring.
let mut flipped = false;
if let Some(top) = self.piles
.get_mut(&from)
.ok_or(MoveError::InvalidSource)?
@@ -376,11 +394,13 @@ impl GameState {
&& !top.face_up
{
top.face_up = true;
flipped = true;
}
self.piles.get_mut(&to).ok_or(MoveError::InvalidDestination)?.cards.append(&mut moved);
self.score = (self.score + score_delta).max(0);
let flip_bonus = if flipped && self.mode != GameMode::Zen { score_flip() } else { 0 };
self.score = (self.score + score_delta + flip_bonus).max(0);
self.move_count = self.move_count.saturating_add(1);
self.is_won = self.check_win();
@@ -407,7 +427,7 @@ impl GameState {
self.score = if self.mode == GameMode::Zen {
0
} else {
(self.score + scoring_undo()).max(0)
(snapshot.score + scoring_undo()).max(0)
};
self.move_count = snapshot.move_count;
self.is_won = false;
@@ -441,11 +461,15 @@ impl GameState {
/// Returns `true` when stock and waste are empty and all tableau cards are face-up.
/// At that point the game can be completed without further player input.
pub fn check_auto_complete(&self) -> bool {
// Stock must be empty; waste may still have cards (they are resolved
// by draw() calls inside next_auto_complete_move / auto_complete_step).
// All three conditions must hold: stock empty, waste empty, and all
// tableau cards face-up. Requiring waste empty avoids the deadlock
// where the waste top cannot reach a foundation directly.
if self.piles.get(&PileType::Stock).is_none_or(|p| !p.cards.is_empty()) {
return false;
}
if self.piles.get(&PileType::Waste).is_none_or(|p| !p.cards.is_empty()) {
return false;
}
(0..7).all(|i| {
self.piles
.get(&PileType::Tableau(i))
@@ -548,11 +572,10 @@ impl GameState {
/// # Precondition
///
/// This function is only called when `is_auto_completable` is `true`.
/// Auto-completability requires the waste pile to be empty, as enforced by
/// [`check_auto_complete`](Self::check_auto_complete) — it returns `false`
/// whenever `piles[Waste]` is non-empty. Therefore, skipping the waste pile
/// in this scan is intentional and correct: by the time this function is
/// reached, there are guaranteed to be no cards there to move.
/// Auto-completability requires both stock and waste to be empty, as
/// enforced by [`check_auto_complete`](Self::check_auto_complete). The
/// waste-pile check in this function is therefore a safety net only; under
/// normal operation the waste is guaranteed empty when this is reached.
pub fn next_auto_complete_move(&self) -> Option<(PileType, PileType)> {
if !self.is_auto_completable || self.is_won {
return None;
@@ -1134,10 +1157,11 @@ mod tests {
}
#[test]
fn auto_complete_true_when_stock_empty_waste_has_cards() {
// Waste no longer blocks auto-complete — draw() drains it during
// auto-complete steps. Only stock-not-empty and face-down tableau
// cards block the flag.
fn auto_complete_blocked_when_waste_has_cards() {
// Waste must also be empty for auto-complete to engage. A non-empty
// waste pile — even with all tableau cards face-up and stock empty —
// must return false to prevent a deadlock where the waste top cannot
// reach a foundation directly.
let mut g = new_game();
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
g.piles.get_mut(&PileType::Waste).unwrap().cards.push(Card {
@@ -1151,7 +1175,7 @@ mod tests {
c.face_up = true;
}
}
assert!(g.check_auto_complete());
assert!(!g.check_auto_complete());
}
#[test]
@@ -1517,6 +1541,126 @@ mod tests {
}
}
// --- Flip bonus (+5) ---
#[test]
fn flip_bonus_awarded_when_face_down_card_exposed() {
let mut g = new_game();
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for i in 0..7 { g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); }
// Tableau(0): hidden Ace under a face-up 5♠
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards = vec![
Card { id: 1, suit: Suit::Hearts, rank: Rank::Ace, face_up: false },
Card { id: 2, suit: Suit::Spades, rank: Rank::Five, face_up: true },
];
// Tableau(1): 6♥ — 5♠ can land here
g.piles.get_mut(&PileType::Tableau(1)).unwrap().cards = vec![
Card { id: 3, suit: Suit::Hearts, rank: Rank::Six, face_up: true },
];
let score_before = g.score;
g.move_cards(PileType::Tableau(0), PileType::Tableau(1), 1).unwrap();
assert_eq!(g.score, score_before + 5, "flip bonus must be +5 when a face-down card is exposed");
assert!(g.piles[&PileType::Tableau(0)].cards[0].face_up, "exposed card must now be face-up");
}
#[test]
fn flip_bonus_not_awarded_when_source_pile_empties() {
let mut g = new_game();
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for i in 0..7 { g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); }
// Only a King in Tableau(0); moving it leaves pile empty — nothing to flip
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards = vec![
Card { id: 1, suit: Suit::Spades, rank: Rank::King, face_up: true },
];
let score_before = g.score;
g.move_cards(PileType::Tableau(0), PileType::Tableau(1), 1).unwrap();
assert_eq!(g.score, score_before, "no flip bonus when source pile becomes empty");
}
#[test]
fn flip_bonus_suppressed_in_zen_mode() {
let mut g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::Zen);
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for i in 0..7 { g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); }
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards = vec![
Card { id: 1, suit: Suit::Hearts, rank: Rank::Ace, face_up: false },
Card { id: 2, suit: Suit::Spades, rank: Rank::Five, face_up: true },
];
g.piles.get_mut(&PileType::Tableau(1)).unwrap().cards = vec![
Card { id: 3, suit: Suit::Hearts, rank: Rank::Six, face_up: true },
];
g.move_cards(PileType::Tableau(0), PileType::Tableau(1), 1).unwrap();
assert_eq!(g.score, 0, "zen mode must suppress flip bonus");
}
// --- Recycle penalty ---
#[test]
fn recycle_penalty_draw1_first_pass_free() {
let mut g = new_game(); // DrawOne
g.score = 200;
while !g.piles[&PileType::Stock].cards.is_empty() { g.draw().unwrap(); }
g.draw().unwrap(); // first recycle — free
assert_eq!(g.recycle_count, 1);
assert_eq!(g.score, 200, "first recycle in Draw-1 must be free");
}
#[test]
fn recycle_penalty_draw1_second_pass_costs_100() {
let mut g = new_game(); // DrawOne
g.score = 200;
// First recycle (free)
while !g.piles[&PileType::Stock].cards.is_empty() { g.draw().unwrap(); }
g.draw().unwrap();
// Second recycle (-100)
while !g.piles[&PileType::Stock].cards.is_empty() { g.draw().unwrap(); }
g.draw().unwrap();
assert_eq!(g.recycle_count, 2);
assert_eq!(g.score, 100, "second recycle in Draw-1 must cost -100");
}
#[test]
fn recycle_penalty_draw3_three_passes_free() {
let mut g = GameState::new(42, DrawMode::DrawThree);
g.score = 200;
for _ in 0..3 {
while !g.piles[&PileType::Stock].cards.is_empty() { g.draw().unwrap(); }
g.draw().unwrap();
}
assert_eq!(g.recycle_count, 3);
assert_eq!(g.score, 200, "first 3 recycles in Draw-3 must be free");
}
#[test]
fn recycle_penalty_draw3_fourth_pass_costs_20() {
let mut g = GameState::new(42, DrawMode::DrawThree);
g.score = 200;
for _ in 0..3 {
while !g.piles[&PileType::Stock].cards.is_empty() { g.draw().unwrap(); }
g.draw().unwrap();
}
// Fourth recycle (-20)
while !g.piles[&PileType::Stock].cards.is_empty() { g.draw().unwrap(); }
g.draw().unwrap();
assert_eq!(g.recycle_count, 4);
assert_eq!(g.score, 180, "fourth recycle in Draw-3 must cost -20");
}
#[test]
fn recycle_penalty_suppressed_in_zen_mode() {
let mut g = GameState::new_with_mode(42, DrawMode::DrawOne, GameMode::Zen);
// Two recycles — second would normally cost -100 in classic mode
for _ in 0..2 {
while !g.piles[&PileType::Stock].cards.is_empty() { g.draw().unwrap(); }
g.draw().unwrap();
}
assert_eq!(g.recycle_count, 2);
assert_eq!(g.score, 0, "zen mode must suppress recycle penalty");
}
#[test]
fn possible_instructions_waste_top_included() {
let mut g = new_game();
@@ -1535,4 +1679,58 @@ mod tests {
"King on waste must be moveable to an empty tableau column"
);
}
// --- P2: waste multi-card move must be rejected ---
#[test]
fn waste_multi_card_move_returns_rule_violation() {
let mut g = new_game();
g.piles.get_mut(&PileType::Waste).unwrap().cards = vec![
Card { id: 1, suit: Suit::Hearts, rank: Rank::Ace, face_up: true },
Card { id: 2, suit: Suit::Spades, rank: Rank::King, face_up: true },
];
for i in 0..7 { g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); }
let result = g.move_cards(PileType::Waste, PileType::Tableau(0), 2);
assert!(matches!(result, Err(MoveError::RuleViolation(_))),
"moving 2 cards from waste must be rejected");
}
// --- P3: foundation-to-foundation move must be rejected ---
#[test]
fn foundation_to_foundation_move_returns_rule_violation() {
let mut g = new_game();
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for i in 0..7 { g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); }
// Place Ace of Clubs on Foundation(0), leave Foundation(1) empty.
g.piles.get_mut(&PileType::Foundation(0)).unwrap().cards = vec![
Card { id: 1, suit: Suit::Clubs, rank: Rank::Ace, face_up: true },
];
// Attempting to move Ace from Foundation(0) to Foundation(1) must fail.
let result = g.move_cards(PileType::Foundation(0), PileType::Foundation(1), 1);
assert!(matches!(result, Err(MoveError::RuleViolation(_))),
"moving between foundation slots must be rejected");
}
// --- P4: undo must not retain points from the undone move ---
#[test]
fn undo_does_not_retain_score_from_undone_move() {
let mut g = new_game();
g.piles.get_mut(&PileType::Stock).unwrap().cards.clear();
g.piles.get_mut(&PileType::Waste).unwrap().cards.clear();
for i in 0..7 { g.piles.get_mut(&PileType::Tableau(i)).unwrap().cards.clear(); }
// Place an Ace on Tableau(0) — moving it to Foundation earns +10.
g.piles.get_mut(&PileType::Tableau(0)).unwrap().cards = vec![
Card { id: 1, suit: Suit::Clubs, rank: Rank::Ace, face_up: true },
];
assert_eq!(g.score, 0);
g.move_cards(PileType::Tableau(0), PileType::Foundation(0), 1).unwrap();
assert_eq!(g.score, 10, "moving Ace to foundation earns +10");
// Undo must roll back to snapshot.score (0) minus the penalty, not keep the +10.
g.undo().unwrap();
// snapshot.score was 0, so result is max(0, 0 - 15) = 0
assert_eq!(g.score, 0, "undo must not retain points from the undone move");
}
}
+44
View File
@@ -5,7 +5,11 @@ use crate::pile::PileType;
/// Windows XP Standard scoring:
/// - +10 for any card reaching a foundation pile
/// - +5 for a waste → tableau move
/// - -15 for a foundation → tableau (take-from-foundation) move
/// - 0 for all other moves
///
/// Note: the +5 flip bonus for exposing a face-down tableau card is applied
/// separately in `game_state::move_cards` because it depends on post-move state.
pub fn score_move(from: &PileType, to: &PileType) -> i32 {
match to {
PileType::Foundation(_) => 10,
@@ -23,6 +27,21 @@ pub fn score_undo() -> i32 {
-15
}
/// Score bonus awarded when a face-down tableau card is flipped face-up: +5.
pub fn score_flip() -> i32 {
5
}
/// Score penalty for recycling the waste pile back to stock.
///
/// Windows standard: the first N recycles are free (N=1 for Draw-1, N=3 for Draw-3).
/// Subsequent recycles cost -100 (Draw-1) or -20 (Draw-3).
/// `recycle_count` is the new total count **after** this recycle.
pub fn score_recycle(recycle_count: u32, is_draw_three: bool) -> i32 {
let (free, penalty) = if is_draw_three { (3_u32, -20_i32) } else { (1_u32, -100_i32) };
if recycle_count > free { penalty } else { 0 }
}
/// Time bonus added to the score on a win: `700_000 / elapsed_seconds`.
/// Returns 0 when `elapsed_seconds` is 0 to avoid division by zero.
pub fn compute_time_bonus(elapsed_seconds: u64) -> i32 {
@@ -93,4 +112,29 @@ mod tests {
let bonus = compute_time_bonus(1);
assert!(bonus >= 0, "time bonus must be non-negative after u64→i32 cast");
}
#[test]
fn flip_bonus_is_five() {
assert_eq!(score_flip(), 5);
}
#[test]
fn recycle_draw1_first_pass_free() {
assert_eq!(score_recycle(1, false), 0);
}
#[test]
fn recycle_draw1_second_pass_penalised() {
assert_eq!(score_recycle(2, false), -100);
}
#[test]
fn recycle_draw3_third_pass_free() {
assert_eq!(score_recycle(3, true), 0);
}
#[test]
fn recycle_draw3_fourth_pass_penalised() {
assert_eq!(score_recycle(4, true), -20);
}
}
+135 -34
View File
@@ -2,7 +2,10 @@
///
/// Tokens are serialised to JSON, encrypted with AES-256/GCM/NoPadding using a
/// device-bound key from the Android Keystore, and written atomically to
/// `{data_dir}/auth_tokens.bin` as `[12-byte IV][ciphertext+GCM-tag]`.
/// `{data_dir}/ferrous_solitaire/auth_tokens.bin` as `[12-byte IV][ciphertext+GCM-tag]`.
///
/// The file stores a `HashMap<String, TokenBlob>` (keyed by username) so that
/// multiple accounts can coexist without silently overwriting each other.
///
/// The Keystore key survives app restarts but is destroyed on uninstall (or if
/// the user changes biometric/lock credentials, in which case decryption fails
@@ -15,6 +18,7 @@ use jni::{
JNIEnv, JavaVM,
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use crate::auth_tokens::TokenError;
@@ -280,21 +284,30 @@ 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"))
}
/// Path where the token file lived before the APP_DIR_NAME subdirectory was
/// introduced. Used only during the one-time migration in `read_map`.
fn legacy_token_file_path() -> Option<PathBuf> {
crate::platform::data_dir().map(|d| d.join("auth_tokens.bin"))
}
fn read_file_bytes() -> Result<Vec<u8>, TokenError> {
let path = token_file_path()
.ok_or_else(|| TokenError::KeychainUnavailable("no data dir".into()))?;
fn read_file_bytes_from(path: &PathBuf) -> Result<Vec<u8>, TokenError> {
if !path.exists() {
return Err(TokenError::NotFound(String::new()));
}
std::fs::read(&path).map_err(|e| TokenError::Keyring(format!("read auth_tokens.bin: {e}")))
std::fs::read(path).map_err(|e| TokenError::Keyring(format!("read auth_tokens.bin: {e}")))
}
fn write_file_bytes(data: &[u8]) -> Result<(), TokenError> {
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}")))?;
}
let tmp = path.with_extension("bin.tmp");
std::fs::write(&tmp, data)
.map_err(|e| TokenError::Keyring(format!("write auth_tokens.bin.tmp: {e}")))?;
@@ -302,29 +315,88 @@ fn write_file_bytes(data: &[u8]) -> Result<(), TokenError> {
.map_err(|e| TokenError::Keyring(format!("rename auth_tokens: {e}")))
}
fn load_blob(username: &str) -> Result<TokenBlob, TokenError> {
let data = read_file_bytes().map_err(|e| match e {
TokenError::NotFound(_) => TokenError::NotFound(username.to_string()),
/// Decrypt raw bytes from the file and deserialise as `HashMap<String, TokenBlob>`.
///
/// Migration strategy:
/// 1. If the new-path file exists, read and decrypt it.
/// - Try to deserialise as `HashMap<String, TokenBlob>`.
/// - On parse failure (old single-blob format), try `TokenBlob` and convert.
/// 2. If the new-path file does NOT exist but the legacy-path file does, migrate:
/// - Read and decrypt the legacy file.
/// - Deserialise as `TokenBlob` (the only format the legacy path ever used).
/// - Write the result to the new path as a single-entry map.
/// - 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 legacy_path = legacy_token_file_path();
// --- 1. New path exists ---
if new_path.exists() {
let data = read_file_bytes_from(&new_path).map_err(|e| match e {
TokenError::NotFound(_) => TokenError::NotFound(String::new()),
other => other,
})?;
if data.len() < 12 {
return Err(TokenError::Keyring("auth_tokens.bin corrupt (too short)".into()));
}
let plaintext = with_jvm(|env| {
let key = load_or_create_key(env)?;
decrypt_gcm(env, &key, &data)
})?;
let blob: TokenBlob = serde_json::from_slice(&plaintext)
.map_err(|e| TokenError::Keyring(format!("JSON decode: {e}")))?;
if blob.username != username {
return Err(TokenError::NotFound(username.to_string()));
// Try the current multi-user format first.
if let Ok(map) = serde_json::from_slice::<HashMap<String, TokenBlob>>(&plaintext) {
return Ok(map);
}
// Fall back: old single-blob format written by an earlier binary.
if let Ok(blob) = serde_json::from_slice::<TokenBlob>(&plaintext) {
let mut map = HashMap::new();
map.insert(blob.username.clone(), blob);
return Ok(map);
}
return Err(TokenError::Keyring("auth_tokens.bin unrecognised format".into()));
}
Ok(blob)
// --- 2. Legacy path migration ---
if let Some(ref lpath) = legacy_path {
if lpath.exists() {
let data = read_file_bytes_from(lpath).map_err(|e| match e {
TokenError::NotFound(_) => TokenError::NotFound(String::new()),
other => other,
})?;
if data.len() >= 12 {
let plaintext = with_jvm(|env| {
let key = load_or_create_key(env)?;
decrypt_gcm(env, &key, &data)
})?;
if let Ok(blob) = serde_json::from_slice::<TokenBlob>(&plaintext) {
let mut map = HashMap::new();
map.insert(blob.username.clone(), blob);
// Write to the new location, then remove the legacy file.
if write_map_inner(&map).is_ok() {
let _ = std::fs::remove_file(lpath);
}
return Ok(map);
}
}
// Legacy file corrupt or unrecognised — treat as empty.
}
}
// --- 3. No file found ---
Ok(HashMap::new())
}
/// 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 encrypted = with_jvm(|env| {
let key = load_or_create_key(env)?;
encrypt_gcm(env, &key, &plaintext)
})?;
write_file_bytes(&encrypted)
}
// ---------------------------------------------------------------------------
@@ -333,46 +405,71 @@ fn load_blob(username: &str) -> Result<TokenBlob, TokenError> {
/// Encrypt and store `access_token` and `refresh_token` for `username`.
///
/// Overwrites any previously stored tokens.
/// If tokens already exist for other usernames they are preserved.
/// Any previously stored tokens for `username` are silently replaced.
pub fn store_tokens(
username: &str,
access_token: &str,
refresh_token: &str,
) -> Result<(), TokenError> {
let blob = TokenBlob {
let mut map = match read_map() {
Ok(m) => m,
// If the file is missing or corrupt, start with an empty map so we
// do not block a fresh login.
Err(TokenError::NotFound(_)) => HashMap::new(),
Err(e) => return Err(e),
};
map.insert(
username.to_string(),
TokenBlob {
username: username.to_string(),
access_token: access_token.to_string(),
refresh_token: refresh_token.to_string(),
};
let plaintext = serde_json::to_vec(&blob)
.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)
})?;
write_file_bytes(&encrypted)
write_map_inner(&map)
}
/// Return the stored access token for `username`.
///
/// Returns [`TokenError::NotFound`] if no token has been stored yet.
/// Returns [`TokenError::NotFound`] if no token has been stored for this username.
pub fn load_access_token(username: &str) -> Result<String, TokenError> {
load_blob(username).map(|b| b.access_token)
let mut map = read_map()?;
map.remove(username)
.map(|b| b.access_token)
.ok_or_else(|| TokenError::NotFound(username.to_string()))
}
/// Return the stored refresh token for `username`.
///
/// Returns [`TokenError::NotFound`] if no token has been stored yet.
/// Returns [`TokenError::NotFound`] if no token has been stored for this username.
pub fn load_refresh_token(username: &str) -> Result<String, TokenError> {
load_blob(username).map(|b| b.refresh_token)
let mut map = read_map()?;
map.remove(username)
.map(|b| b.refresh_token)
.ok_or_else(|| TokenError::NotFound(username.to_string()))
}
/// Delete stored tokens and remove the Keystore key for `username`.
/// Delete stored tokens for `username`.
///
/// If other usernames have stored tokens they are left untouched.
/// When this is the last entry in the map the Keystore key is also removed so
/// a future re-login generates a fresh key.
///
/// Missing file or missing Keystore entry are silently ignored.
pub fn delete_tokens(_username: &str) -> Result<(), TokenError> {
pub fn delete_tokens(username: &str) -> Result<(), TokenError> {
let mut map = match read_map() {
Ok(m) => m,
Err(TokenError::NotFound(_)) => return Ok(()), // nothing to delete
Err(e) => return Err(e),
};
map.remove(username);
if map.is_empty() {
// No more users — remove the file and the Keystore key.
if let Some(path) = token_file_path() {
if path.exists() {
std::fs::remove_file(&path)
@@ -406,4 +503,8 @@ pub fn delete_tokens(_username: &str) -> Result<(), TokenError> {
env.call_method(&ks, "deleteEntry", "(Ljava/lang/String;)V", &[alias.borrow()])?
.v()
})
} else {
// Other users still exist — just rewrite the map without this user.
write_map_inner(&map)
}
}
+14 -4
View File
@@ -45,19 +45,29 @@ pub struct AnalyticsPlugin;
impl Plugin for AnalyticsPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<AnalyticsResource>()
.init_resource::<TokioRuntimeResource>()
.add_systems(Startup, init_analytics)
.add_systems(
Update,
(
react_to_settings_change,
on_game_won,
on_forfeit,
on_new_game,
on_achievement_unlocked,
tick_flush_timer,
),
);
// Build the shared Tokio runtime; skip network flush systems if the OS
// refuses to create threads (resource-limited / sandboxed environments).
match TokioRuntimeResource::new() {
Ok(rt) => {
app.insert_resource(rt).add_systems(
Update,
(on_game_won, on_forfeit, tick_flush_timer),
);
}
Err(e) => {
bevy::log::warn!("analytics_plugin: Tokio runtime unavailable — analytics flush disabled: {e}");
}
}
}
}
+13 -2
View File
@@ -48,10 +48,21 @@ pub struct AvatarPlugin;
impl Plugin for AvatarPlugin {
fn build(&self, app: &mut App) {
app.add_message::<AvatarFetchEvent>()
.init_resource::<TokioRuntimeResource>()
.init_resource::<AvatarResource>()
.init_resource::<PendingAvatarTask>()
.add_systems(Update, (handle_avatar_fetch, poll_avatar_task));
.add_systems(Update, poll_avatar_task);
// Build the shared Tokio runtime; skip avatar download if the OS
// refuses to create threads (resource-limited / sandboxed environments).
match TokioRuntimeResource::new() {
Ok(rt) => {
app.insert_resource(rt)
.add_systems(Update, handle_avatar_fetch);
}
Err(e) => {
bevy::log::warn!("avatar_plugin: Tokio runtime unavailable — avatar fetch disabled: {e}");
}
}
}
}
+22 -39
View File
@@ -32,7 +32,7 @@ use crate::font_plugin::FontResource;
use crate::resources::{DragState, GameStateResource, SyncStatusResource};
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
spawn_modal_header, ButtonVariant,
spawn_modal_header, ButtonVariant, ModalScrim,
};
use crate::ui_theme;
@@ -431,6 +431,7 @@ fn handle_new_game(
game_over_screens: Query<Entity, With<GameOverScreen>>,
layout: Option<Res<crate::layout::LayoutResource>>,
mut card_transforms: Query<&mut Transform, With<crate::card_plugin::CardEntity>>,
scrims: Query<(), With<ModalScrim>>,
) {
for ev in new_game.read() {
// If an active game is in progress, intercept and show a confirm dialog.
@@ -440,8 +441,12 @@ fn handle_new_game(
// duplicates) or if the event itself was already confirmed by the
// player pressing Y on the modal — without the `confirmed` check the
// modal would be respawned the frame after the despawn flushes.
// Also skip if any other modal scrim is currently open (global guard).
let confirm_already_open = !confirm_screens.is_empty();
if needs_confirm && !confirm_already_open && !ev.confirmed {
if !scrims.is_empty() {
return;
}
// Despawn any stale game-over overlay before showing confirm dialog.
for entity in &game_over_screens {
commands.entity(entity).despawn();
@@ -576,10 +581,14 @@ fn spawn_restore_prompt_if_pending(
splash: Query<(), With<crate::splash_plugin::SplashRoot>>,
existing: Query<(), With<RestorePromptScreen>>,
font_res: Option<Res<FontResource>>,
scrims: Query<(), With<ModalScrim>>,
) {
if pending.0.is_none() || !splash.is_empty() || !existing.is_empty() {
return;
}
if !scrims.is_empty() {
return;
}
spawn_modal(
&mut commands,
RestorePromptScreen,
@@ -1036,9 +1045,7 @@ pub fn record_replay_on_win(
/// previous heuristic incorrectly did (Quat hit this with 4 cards
/// remaining and the game just sat there).
pub fn has_legal_moves(game: &GameState) -> bool {
use solitaire_core::card::Card;
use solitaire_core::pile::PileType;
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
// Drawing from a non-empty stock, and recycling a non-empty waste back to
// stock, are always legal moves in standard Klondike (unlimited recycles).
@@ -1049,40 +1056,14 @@ pub fn has_legal_moves(game: &GameState) -> bool {
return true;
}
// Stock and waste exhausted — check whether any visible card can be placed.
let mut sources: Vec<Card> = Vec::new();
// Top waste card (waste is empty here, but included for completeness).
if let Some(p) = game.piles.get(&PileType::Waste)
&& let Some(top) = p.cards.last()
{
sources.push(top.clone());
}
// Any face-up card in a tableau column can be the base of a movable run.
for i in 0..7_usize {
if let Some(t) = game.piles.get(&PileType::Tableau(i)) {
for card in t.cards.iter().filter(|c| c.face_up) {
sources.push(card.clone());
}
}
}
for card in &sources {
for slot in 0..4_u8 {
if let Some(dest) = game.piles.get(&PileType::Foundation(slot))
&& can_place_on_foundation(card, dest)
{
return true;
}
}
for i in 0..7_usize {
if let Some(dest) = game.piles.get(&PileType::Tableau(i))
&& can_place_on_tableau(card, dest)
{
return true;
}
}
}
false
// Stock and waste both exhausted — delegate to the authoritative move
// enumeration in core, which validates tableau sequence structure and
// foundation placement correctly. The previous hand-rolled loop only
// checked can_place_on_tableau(card, dest) for individual face-up cards
// without verifying that the cards above them form a valid alternating run,
// causing false positives when a useful-looking card was buried under an
// invalid sequence.
!game.possible_instructions().is_empty()
}
/// After each `StateChangedEvent`, check if the game has no legal moves.
@@ -1100,6 +1081,7 @@ fn check_no_moves(
mut already_fired: Local<bool>,
game_over_screens: Query<Entity, With<GameOverScreen>>,
font_res: Option<Res<FontResource>>,
scrims: Query<(), With<ModalScrim>>,
) {
// Reset the debounce flag on every state change so if something changes
// we re-evaluate on the next state change.
@@ -1131,8 +1113,9 @@ fn check_no_moves(
let no_moves_msg = "No moves available \u{2014} press D to draw or N for a new game";
toast.write(InfoToastEvent(no_moves_msg.to_string()));
*already_fired = true;
// Only spawn the overlay if one does not already exist.
if game_over_screens.is_empty() {
// Only spawn the overlay if one does not already exist, and no other
// modal scrim is currently open (global ModalScrim guard).
if game_over_screens.is_empty() && scrims.is_empty() {
spawn_game_over_screen(&mut commands, game.0.score, font_res.as_deref());
}
}
+1 -31
View File
@@ -3,7 +3,7 @@
use std::sync::Arc;
use bevy::math::Vec2;
use bevy::prelude::{warn, Resource};
use bevy::prelude::Resource;
use chrono::{DateTime, Utc};
use solitaire_core::game_state::GameState;
use solitaire_core::pile::PileType;
@@ -146,33 +146,3 @@ impl TokioRuntimeResource {
}
}
impl Default for TokioRuntimeResource {
fn default() -> Self {
// Try multi-threaded first; fall back to current-thread (single
// worker) if the OS refuses to create additional threads. Neither
// path uses `.expect()` so this never panics at startup.
match tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
{
Ok(rt) => Self(Arc::new(rt)),
Err(e) => {
warn!(
"sync: failed to build multi-thread Tokio runtime ({e}); \
falling back to current-thread runtime"
);
// current_thread runtime never spawns OS threads, so it
// succeeds even under tight sandboxing.
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect(
"current-thread Tokio runtime failed — \
the process cannot do any async I/O",
);
Self(Arc::new(rt))
}
}
}
}
+2 -2
View File
@@ -146,7 +146,6 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
.route("/api/account", delete(auth::delete_account))
.route("/api/me", get(auth::get_me))
.route("/api/me/avatar", put(auth::upload_avatar))
.nest_service("/avatars", ServeDir::new("avatars"))
.layer(axum_middleware::from_fn_with_state(
state.clone(),
middleware::require_auth,
@@ -198,7 +197,8 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
.route("/api/daily-challenge", get(challenge::daily_challenge))
.route("/api/replays/recent", get(replays::recent))
.route("/api/replays/{id}", get(replays::get_by_id))
.route("/health", get(health));
.route("/health", get(health))
.nest_service("/avatars", ServeDir::new("avatars"));
// Replay web UI: a single HTML page served at `/replays/:id` plus a
// ServeDir for the static assets (`web/index.html`, `web/replay.css`,
+61
View File
@@ -355,6 +355,67 @@ main {
animation: illegal-shake 320ms ease;
}
/* ── No-moves banner ─────────────────────────────────────────────────── */
#no-moves-banner {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 900;
animation: slide-up 240ms ease;
}
#no-moves-banner.hidden { display: none; }
.no-moves-card {
background: var(--panel);
border: 1px solid rgba(255,255,255,0.15);
border-radius: 12px;
padding: 20px 32px;
text-align: center;
display: flex;
flex-direction: column;
gap: 12px;
box-shadow: 0 8px 32px rgba(0,0,0,0.7);
min-width: 300px;
}
.no-moves-title {
font-size: 18px;
font-weight: 700;
color: var(--accent);
}
.no-moves-detail {
font-size: 13px;
color: var(--text-muted);
margin: 0;
line-height: 1.5;
}
.no-moves-actions {
display: flex;
gap: 10px;
justify-content: center;
margin-top: 4px;
}
.no-moves-actions button.secondary {
background: transparent;
border: 1px solid rgba(255,255,255,0.2);
color: var(--text-muted);
}
.no-moves-actions button.secondary:hover {
background: rgba(255,255,255,0.05);
}
@keyframes slide-up {
from { opacity: 0; transform: translateX(-50%) translateY(12px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
/* ── Foundation slot suit hints ──────────────────────────────────────────── */
.slot-hint {
+11
View File
@@ -77,6 +77,17 @@
</div>
</div>
<div id="no-moves-banner" class="hidden">
<div class="no-moves-card">
<div class="no-moves-title">No Moves Available</div>
<p class="no-moves-detail">No legal moves remain. Undo to go back or start a new game.</p>
<div class="no-moves-actions">
<button id="btn-no-moves-undo">↩ Undo</button>
<button id="btn-no-moves-new" class="secondary">↺ New Game</button>
</div>
</div>
</div>
<script type="module" src="/web/game.js"></script>
</body>
</html>
+9 -3
View File
@@ -141,6 +141,7 @@ const winScore = document.getElementById("win-score");
const winMoves = document.getElementById("win-moves");
const winTime = document.getElementById("win-time");
const btnWinNew = document.getElementById("btn-win-new");
const noMovesBanner = document.getElementById("no-moves-banner");
// ── Scale to fit ─────────────────────────────────────────────────────────────
// Scales #card-area to fill #board without overflowing either dimension.
@@ -391,9 +392,12 @@ function render(s) {
clearSave();
stopTimer();
if (acTimer) { clearInterval(acTimer); acTimer = null; }
if (noMovesBanner) noMovesBanner.classList.add("hidden");
showWin(s);
} else {
saveState();
const noMoves = !s.has_moves && !s.is_auto_completable;
if (noMovesBanner) noMovesBanner.classList.toggle("hidden", !noMoves);
}
}
@@ -431,12 +435,12 @@ async function submitReplay(s) {
const payload = {
schema_version: 1,
seed: Math.round(game.seed()),
draw_mode: drawThree ? "draw_three" : "draw_one",
mode: "classic",
draw_mode: drawThree ? "DrawThree" : "DrawOne",
mode: "Classic",
time_seconds: elapsedSecs,
final_score: s.score,
move_count: s.move_count,
recorded_at: new Date().toISOString(),
recorded_at: new Date().toISOString().slice(0, 10),
moves: [],
};
try {
@@ -479,6 +483,8 @@ function attachHandlers() {
btnUndo.addEventListener("click", doUndo);
btnBoardUndo.addEventListener("click", doUndo);
btnNew.addEventListener("click", () => startGame(randomSeed()));
document.getElementById("btn-no-moves-undo")?.addEventListener("click", doUndo);
document.getElementById("btn-no-moves-new")?.addEventListener("click", () => startGame(randomSeed()));
btnWinNew.addEventListener("click", () => startGame(randomSeed()));
chkDraw3.addEventListener("change", () => {
drawThree = chkDraw3.checked;
+55 -4
View File
@@ -135,8 +135,21 @@ fn merge_stats(
fastest_win_seconds: local.fastest_win_seconds.min(remote.fastest_win_seconds),
lifetime_score: local.lifetime_score.max(remote.lifetime_score),
best_single_score: local.best_single_score.max(remote.best_single_score),
draw_one_wins: local.draw_one_wins.max(remote.draw_one_wins),
draw_three_wins: local.draw_three_wins.max(remote.draw_three_wins),
// Take per-mode win counts from whichever side contributed `games_won`
// (the side with the higher total). Independent max() calls can push
// draw_one_wins + draw_three_wins above games_won when the two sides
// have complementary win histories (e.g. local has 20 draw-one wins,
// remote has 20 draw-three wins, each with games_won = 20).
draw_one_wins: if local.games_won >= remote.games_won {
local.draw_one_wins
} else {
remote.draw_one_wins
},
draw_three_wins: if local.games_won >= remote.games_won {
local.draw_three_wins
} else {
remote.draw_three_wins
},
// Per-mode bests. Bests take max; fastest times take a *zero-aware*
// min — see [`min_ignore_zero`] for the rationale (0 means "no win
// yet" for these fields, unlike the lifetime `fastest_win_seconds`
@@ -505,17 +518,55 @@ mod tests {
}
#[test]
fn stats_draw_mode_wins_take_max() {
fn stats_draw_mode_wins_taken_from_winning_side() {
// Both sides have equal games_won (default 0), so local is chosen (>=).
// Per-mode counts come entirely from that one side — no cross-side max.
let mut local = default_payload();
local.stats.games_won = 25;
local.stats.draw_one_wins = 20;
local.stats.draw_three_wins = 5;
let mut remote = default_payload();
remote.stats.games_won = 15;
remote.stats.draw_one_wins = 15;
remote.stats.draw_three_wins = 8;
// local has more wins, so local's per-mode counts are used.
let (merged, _) = merge(&local, &remote);
assert_eq!(merged.stats.games_won, 25);
assert_eq!(merged.stats.draw_one_wins, 20);
assert_eq!(merged.stats.draw_three_wins, 8);
assert_eq!(merged.stats.draw_three_wins, 5);
assert!(
merged.stats.draw_one_wins + merged.stats.draw_three_wins
<= merged.stats.games_won,
"draw-mode win counts must not exceed total wins"
);
}
#[test]
fn merge_stats_draw_mode_wins_do_not_exceed_total() {
// local: 20 draw-one wins, 0 draw-three, games_won = 20
// remote: 0 draw-one wins, 20 draw-three, games_won = 20
// Without the fix, independent max() calls yield draw_one=20, draw_three=20,
// games_won=20 — the breakdown sums to 40, double the actual total.
let mut local = default_payload();
local.stats.games_won = 20;
local.stats.draw_one_wins = 20;
local.stats.draw_three_wins = 0;
let mut remote = default_payload();
remote.stats.games_won = 20;
remote.stats.draw_one_wins = 0;
remote.stats.draw_three_wins = 20;
let (merged, _) = merge(&local, &remote);
assert!(
merged.stats.draw_one_wins + merged.stats.draw_three_wins <= merged.stats.games_won,
"draw-mode win counts must not exceed total wins after merge: \
draw_one={}, draw_three={}, games_won={}",
merged.stats.draw_one_wins,
merged.stats.draw_three_wins,
merged.stats.games_won,
);
}
#[test]
+8
View File
@@ -241,6 +241,8 @@ pub struct GameSnapshot {
pub move_count: u32,
pub is_won: bool,
pub is_auto_completable: bool,
/// `false` when stock, waste, and all pile-to-pile moves are exhausted.
pub has_moves: bool,
pub undo_count: u32,
/// Number of snapshots currently on the undo stack; 0 means undo is unavailable.
pub undo_stack_len: usize,
@@ -279,11 +281,17 @@ impl SolitaireGame {
.map(|p| p.cards.iter().map(CardSnapshot::from).collect())
.unwrap_or_default()
};
let has_moves = {
let stock_empty = self.game.piles.get(&PileType::Stock).is_none_or(|p| p.cards.is_empty());
let waste_empty = self.game.piles.get(&PileType::Waste).is_none_or(|p| p.cards.is_empty());
!stock_empty || !waste_empty || !self.game.possible_instructions().is_empty()
};
GameSnapshot {
score: self.game.score,
move_count: self.game.move_count,
is_won: self.game.is_won,
is_auto_completable: self.game.is_auto_completable,
has_moves,
undo_count: self.game.undo_count,
undo_stack_len: self.game.undo_stack_len(),
stock: cards(PileType::Stock),