Compare commits

..

10 Commits

Author SHA1 Message Date
funman300 22661eac66 fix(wasm): rebuild pkg with take_from_foundation fix (closes #36)
Build and Deploy / build-and-push (push) Failing after 4m31s
The binary in pkg/ was built on May 18, predating commit 3322fd4
(fix(wasm): enable take-from-foundation in web game client, May 19).
Dragging Foundation cards to Tableau was silently rejected because
take_from_foundation was false in the stale binary.

Rebuilt with ./build_wasm.sh against current solitaire_core.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 13:41:24 -07:00
funman300 a5a81ccc8e test(core): possible_instructions Foundation→Tableau coverage
Add two tests verifying that possible_instructions includes
Foundation→Tableau moves when take_from_foundation is enabled,
and excludes them when it is disabled.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 13:26:42 -07:00
funman300 e3188faddc fix(engine): foundation→tableau drag hints, z-lift, and Android battery drain
Fixes #34, #35, #36

- all_hints: add Foundation as source for Tableau hints (guarded by
  take_from_foundation); previously H key never suggested Foundation→Tableau
- end_drag / touch_end_drag: enforce take_from_foundation at input layer
  so a rejected-by-core MoveRequestEvent is never fired
- animation_plugin: pub CARD_ANIM_Z_LIFT so card_plugin can consume it
- update_card_entity: set CardAnim start.z = z + CARD_ANIM_Z_LIFT to
  eliminate 1-frame z artifact where animated card appeared behind resting cards
- solitaire_app: use AutoVsync on Android (caps GPU at display Hz vs
  spinning at 200+ fps); add WinitSettings unfocused reactive_low_power
  so app draws ~1fps when backgrounded

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 13:17:28 -07:00
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
15 changed files with 257 additions and 69 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 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 sudo mv kustomize /usr/local/bin/kustomize
- name: Pin image tag in deploy manifests - name: Pin image tag and push to deploy branch
run: |
cd deploy
kustomize edit set image solitaire-server=${{ env.IMAGE }}:${{ steps.meta.outputs.sha }}
- name: Commit and push updated kustomization
run: | run: |
git config user.email "ci@gitea.local" git config user.email "ci@gitea.local"
git config user.name "Gitea CI" 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 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]" git commit -m "chore(deploy): bump image to ${{ steps.meta.outputs.sha }} [skip ci]"
for i in 1 2 3; do git push origin deploy
git pull --rebase origin master && git push && break
sleep 5
done
+1 -1
View File
@@ -7,7 +7,7 @@ spec:
project: default project: default
source: source:
repoURL: https://git.aleshym.co/funman300/Ferrous-Solitaire.git repoURL: https://git.aleshym.co/funman300/Ferrous-Solitaire.git
targetRevision: master targetRevision: deploy
path: deploy path: deploy
destination: destination:
server: https://kubernetes.default.svc server: https://kubernetes.default.svc
+1 -1
View File
@@ -20,4 +20,4 @@ resources:
images: images:
- name: solitaire-server - name: solitaire-server
newName: git.aleshym.co/funman300/solitaire-server newName: git.aleshym.co/funman300/solitaire-server
newTag: 90eb5fd2 newTag: da601beb
+34 -6
View File
@@ -19,6 +19,8 @@ use std::time::{SystemTime, UNIX_EPOCH};
use bevy::prelude::*; use bevy::prelude::*;
use bevy::window::{MonitorSelection, PresentMode, WindowPosition}; use bevy::window::{MonitorSelection, PresentMode, WindowPosition};
#[cfg(target_os = "android")]
use bevy::winit::{UpdateMode, WinitSettings};
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
use bevy::window::{Monitor, PrimaryMonitor, PrimaryWindow}; use bevy::window::{Monitor, PrimaryMonitor, PrimaryWindow};
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
@@ -112,12 +114,22 @@ pub fn run() {
name: Some("ferrous-solitaire".into()), name: Some("ferrous-solitaire".into()),
resolution: window_resolution, resolution: window_resolution,
position: window_position, position: window_position,
// AutoNoVsync prefers Mailbox (triple-buffered) and // On Android, AutoVsync caps the GPU at the display
// falls back to Immediate, eliminating the vsync stall // refresh rate (~60-90 fps). Without it the renderer
// that AutoVsync produces during continuous window // spins as fast as the hardware allows, keeping the
// resize on X11 / Wayland. The game's frame budget is // GPU fully loaded and draining the battery even when
// small enough that a few stray dropped frames from // the game is completely idle.
// disabling vsync are imperceptible. //
// On desktop (X11 / Wayland) AutoNoVsync prefers
// Mailbox (triple-buffered) and falls back to
// Immediate, eliminating the vsync stall that
// AutoVsync produces during continuous window resize.
// The game's frame budget is small enough that a few
// stray dropped frames from disabling vsync are
// imperceptible on desktop.
#[cfg(target_os = "android")]
present_mode: PresentMode::AutoVsync,
#[cfg(not(target_os = "android"))]
present_mode: PresentMode::AutoNoVsync, present_mode: PresentMode::AutoNoVsync,
// Android windows always fill the screen; max_width/max_height // Android windows always fill the screen; max_width/max_height
// default to 0.0, which panics Bevy's clamp when min > max. // default to 0.0, which panics Bevy's clamp when min > max.
@@ -204,6 +216,22 @@ pub fn run() {
.add_plugins(SplashPlugin) .add_plugins(SplashPlugin)
.add_plugins(DiagnosticsHudPlugin); .add_plugins(DiagnosticsHudPlugin);
// On Android the default WinitSettings use UpdateMode::Continuous for
// the focused window, which means Bevy renders as fast as possible even
// when the game is completely idle. Switching to reactive_low_power with
// a 1-second ceiling when the app is backgrounded cuts wake-up frequency
// from ~60 Hz to ≤1 Hz, dramatically reducing background battery drain.
//
// The focused mode stays Continuous so that card-slide animations remain
// smooth. PresentMode::AutoVsync (set above) keeps the GPU capped at the
// display refresh rate (~60 Hz) when foregrounded, which already prevents
// the GPU from spinning at 200+ fps between vsync intervals.
#[cfg(target_os = "android")]
app.insert_resource(WinitSettings {
focused_mode: UpdateMode::Continuous,
unfocused_mode: UpdateMode::reactive_low_power(std::time::Duration::from_secs(1)),
});
// Wire the runtime window icon. Bevy 0.18 has no first-class // Wire the runtime window icon. Bevy 0.18 has no first-class
// `Window::icon` field; the icon is set through the underlying // `Window::icon` field; the icon is set through the underlying
// `winit::window::Window` via `WinitWindows`. Android draws its // `winit::window::Window` via `WinitWindows`. Android draws its
+23
View File
@@ -1680,6 +1680,29 @@ mod tests {
); );
} }
#[test]
fn possible_instructions_includes_foundation_to_tableau_when_enabled() {
// Reuse the Foundation→Tableau board setup (Foundation(0): A♠,2♠; Tableau(0): 3♥).
let g = setup_take_from_foundation_game();
assert!(g.take_from_foundation);
let moves = g.possible_instructions();
assert!(
moves.contains(&(PileType::Foundation(0), PileType::Tableau(0), 1)),
"possible_instructions must include Foundation→Tableau when take_from_foundation is on; got {moves:?}"
);
}
#[test]
fn possible_instructions_excludes_foundation_to_tableau_when_disabled() {
let mut g = setup_take_from_foundation_game();
g.take_from_foundation = false;
let moves = g.possible_instructions();
assert!(
!moves.iter().any(|(from, _, _)| matches!(from, PileType::Foundation(_))),
"possible_instructions must not include any Foundation source when take_from_foundation is off; got {moves:?}"
);
}
// --- P2: waste multi-card move must be rejected --- // --- P2: waste multi-card move must be rejected ---
#[test] #[test]
+1 -1
View File
@@ -81,7 +81,7 @@ const VOLUME_TOAST_SECS: f32 = 1.4;
/// ///
/// 50.0 sits comfortably above the highest pile depth (~1.04) and well below /// 50.0 sits comfortably above the highest pile depth (~1.04) and well below
/// `DRAG_Z` (500), so a dragged card always renders above an animated one. /// `DRAG_Z` (500), so a dragged card always renders above an animated one.
const CARD_ANIM_Z_LIFT: f32 = 50.0; pub const CARD_ANIM_Z_LIFT: f32 = 50.0;
/// Per-card stagger interval for the win cascade at Normal speed (seconds). /// Per-card stagger interval for the win cascade at Normal speed (seconds).
/// ///
+7 -2
View File
@@ -23,7 +23,7 @@ use solitaire_core::pile::PileType;
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau}; use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
use crate::animation_plugin::{CardAnim, EffectiveSlideDuration}; use crate::animation_plugin::{CardAnim, EffectiveSlideDuration, CARD_ANIM_Z_LIFT};
use crate::card_animation::CardAnimation; use crate::card_animation::CardAnimation;
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent, StateChangedEvent}; use crate::events::{CardFaceRevealedEvent, CardFlippedEvent, StateChangedEvent};
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
@@ -963,7 +963,12 @@ fn update_card_entity(
if !has_card_animation { if !has_card_animation {
// Slide to the new position when it differs meaningfully; snap otherwise. // Slide to the new position when it differs meaningfully; snap otherwise.
if (cur.truncate() - target.truncate()).length() > 1.0 && slide_secs > 0.0 { if (cur.truncate() - target.truncate()).length() > 1.0 && slide_secs > 0.0 {
let start = Vec3::new(cur.x, cur.y, z); // update Z immediately // Lift the card immediately on the first frame of the animation so
// it never appears behind a card that is already resting at the
// destination slot. `advance_card_anims` will maintain this lift
// throughout the tween and snap to `target` (without lift) on
// completion.
let start = Vec3::new(cur.x, cur.y, z + CARD_ANIM_Z_LIFT);
commands commands
.entity(entity) .entity(entity)
.insert(Transform::from_translation(start)) .insert(Transform::from_translation(start))
+8 -36
View File
@@ -1045,9 +1045,7 @@ pub fn record_replay_on_win(
/// previous heuristic incorrectly did (Quat hit this with 4 cards /// previous heuristic incorrectly did (Quat hit this with 4 cards
/// remaining and the game just sat there). /// remaining and the game just sat there).
pub fn has_legal_moves(game: &GameState) -> bool { pub fn has_legal_moves(game: &GameState) -> bool {
use solitaire_core::card::Card;
use solitaire_core::pile::PileType; 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 // Drawing from a non-empty stock, and recycling a non-empty waste back to
// stock, are always legal moves in standard Klondike (unlimited recycles). // stock, are always legal moves in standard Klondike (unlimited recycles).
@@ -1058,40 +1056,14 @@ pub fn has_legal_moves(game: &GameState) -> bool {
return true; return true;
} }
// Stock and waste exhausted — check whether any visible card can be placed. // Stock and waste both exhausted — delegate to the authoritative move
let mut sources: Vec<Card> = Vec::new(); // enumeration in core, which validates tableau sequence structure and
// Top waste card (waste is empty here, but included for completeness). // foundation placement correctly. The previous hand-rolled loop only
if let Some(p) = game.piles.get(&PileType::Waste) // checked can_place_on_tableau(card, dest) for individual face-up cards
&& let Some(top) = p.cards.last() // without verifying that the cards above them form a valid alternating run,
{ // causing false positives when a useful-looking card was buried under an
sources.push(top.clone()); // invalid sequence.
} !game.possible_instructions().is_empty()
// 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
} }
/// After each `StateChangedEvent`, check if the game has no legal moves. /// After each `StateChangedEvent`, check if the game has no legal moves.
+32 -2
View File
@@ -734,7 +734,12 @@ fn end_drag(
.is_some_and(|p| can_place_on_foundation(&bottom_card, p)) .is_some_and(|p| can_place_on_foundation(&bottom_card, p))
} }
PileType::Tableau(_) => { PileType::Tableau(_) => {
game.0.piles.get(&target) // Enforce the take-from-foundation rule at the input layer so the
// engine never fires a MoveRequestEvent that game_state would reject.
let foundation_allowed = !matches!(&origin, PileType::Foundation(_))
|| game.0.take_from_foundation;
foundation_allowed
&& game.0.piles.get(&target)
.is_some_and(|p| can_place_on_tableau(&bottom_card, p)) .is_some_and(|p| can_place_on_tableau(&bottom_card, p))
} }
_ => false, _ => false,
@@ -988,7 +993,12 @@ fn touch_end_drag(
.is_some_and(|p| can_place_on_foundation(&bottom_card, p)) .is_some_and(|p| can_place_on_foundation(&bottom_card, p))
} }
PileType::Tableau(_) => { PileType::Tableau(_) => {
game.0.piles.get(&target) // Enforce the take-from-foundation rule at the input layer so the
// engine never fires a MoveRequestEvent that game_state would reject.
let foundation_allowed = !matches!(&origin, PileType::Foundation(_))
|| game.0.take_from_foundation;
foundation_allowed
&& game.0.piles.get(&target)
.is_some_and(|p| can_place_on_tableau(&bottom_card, p)) .is_some_and(|p| can_place_on_tableau(&bottom_card, p))
} }
_ => false, _ => false,
@@ -1591,6 +1601,26 @@ pub fn all_hints(game: &GameState) -> Vec<(PileType, PileType, usize)> {
} }
} }
// Pass 2b — Foundation → Tableau moves (only when the rule allows it).
// Foundation piles are excluded from Pass 1 & 2's source list because they
// should never hint Foundation→Foundation. Here we handle the return path
// separately so the guarded `take_from_foundation` rule is respected.
if game.take_from_foundation {
for slot in 0..4_u8 {
let from = PileType::Foundation(slot);
let Some(from_pile) = game.piles.get(&from) else { continue };
let Some(card) = from_pile.cards.last().filter(|c| c.face_up) else { continue };
for i in 0..7_usize {
let dest = PileType::Tableau(i);
if let Some(dest_pile) = game.piles.get(&dest)
&& can_place_on_tableau(card, dest_pile) {
hints.push((from.clone(), dest, 1));
break;
}
}
}
}
// Pass 3 — suggest drawing from the stock when no other hint was found. // Pass 3 — suggest drawing from the stock when no other hint was found.
if hints.is_empty() { if hints.is_empty() {
let stock_non_empty = game.piles.get(&PileType::Stock) let stock_non_empty = game.piles.get(&PileType::Stock)
+61
View File
@@ -355,6 +355,67 @@ main {
animation: illegal-shake 320ms ease; 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 ──────────────────────────────────────────── */ /* ── Foundation slot suit hints ──────────────────────────────────────────── */
.slot-hint { .slot-hint {
+11
View File
@@ -77,6 +77,17 @@
</div> </div>
</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> <script type="module" src="/web/game.js"></script>
</body> </body>
</html> </html>
+6
View File
@@ -141,6 +141,7 @@ const winScore = document.getElementById("win-score");
const winMoves = document.getElementById("win-moves"); const winMoves = document.getElementById("win-moves");
const winTime = document.getElementById("win-time"); const winTime = document.getElementById("win-time");
const btnWinNew = document.getElementById("btn-win-new"); const btnWinNew = document.getElementById("btn-win-new");
const noMovesBanner = document.getElementById("no-moves-banner");
// ── Scale to fit ───────────────────────────────────────────────────────────── // ── Scale to fit ─────────────────────────────────────────────────────────────
// Scales #card-area to fill #board without overflowing either dimension. // Scales #card-area to fill #board without overflowing either dimension.
@@ -391,9 +392,12 @@ function render(s) {
clearSave(); clearSave();
stopTimer(); stopTimer();
if (acTimer) { clearInterval(acTimer); acTimer = null; } if (acTimer) { clearInterval(acTimer); acTimer = null; }
if (noMovesBanner) noMovesBanner.classList.add("hidden");
showWin(s); showWin(s);
} else { } else {
saveState(); saveState();
const noMoves = !s.has_moves && !s.is_auto_completable;
if (noMovesBanner) noMovesBanner.classList.toggle("hidden", !noMoves);
} }
} }
@@ -479,6 +483,8 @@ function attachHandlers() {
btnUndo.addEventListener("click", doUndo); btnUndo.addEventListener("click", doUndo);
btnBoardUndo.addEventListener("click", doUndo); btnBoardUndo.addEventListener("click", doUndo);
btnNew.addEventListener("click", () => startGame(randomSeed())); 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())); btnWinNew.addEventListener("click", () => startGame(randomSeed()));
chkDraw3.addEventListener("change", () => { chkDraw3.addEventListener("change", () => {
drawThree = chkDraw3.checked; drawThree = chkDraw3.checked;
+48 -2
View File
@@ -1,5 +1,3 @@
/* @ts-self-types="./solitaire_wasm.d.ts" */
/** /**
* Browser-side replay state machine. Owns a live `GameState` and the * Browser-side replay state machine. Owns a live `GameState` and the
* replay's move list; each `step()` applies the next move. * replay's move list; each `step()` applies the next move.
@@ -94,6 +92,12 @@ if (Symbol.dispose) ReplayPlayer.prototype[Symbol.dispose] = ReplayPlayer.protot
* full pile snapshot at any time without mutating state. * full pile snapshot at any time without mutating state.
*/ */
export class SolitaireGame { export class SolitaireGame {
static __wrap(ptr) {
const obj = Object.create(SolitaireGame.prototype);
obj.__wbg_ptr = ptr;
SolitaireGameFinalization.register(obj, obj.__wbg_ptr, obj);
return obj;
}
__destroy_into_raw() { __destroy_into_raw() {
const ptr = this.__wbg_ptr; const ptr = this.__wbg_ptr;
this.__wbg_ptr = 0; this.__wbg_ptr = 0;
@@ -125,6 +129,23 @@ export class SolitaireGame {
const ret = wasm.solitairegame_draw(this.__wbg_ptr); const ret = wasm.solitairegame_draw(this.__wbg_ptr);
return ret; return ret;
} }
/**
* Restore a game from a JSON string previously produced by [`SolitaireGame::serialize`].
*
* Returns an error string if the JSON is malformed or describes a state
* that can't be deserialised (e.g. from a future schema version).
* @param {string} json
* @returns {SolitaireGame}
*/
static from_saved(json) {
const ptr0 = passStringToWasm0(json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.solitairegame_from_saved(ptr0, len0);
if (ret[2]) {
throw takeFromExternrefTable0(ret[1]);
}
return SolitaireGame.__wrap(ret[0]);
}
/** /**
* Move `count` cards from pile `from` to pile `to`. * Move `count` cards from pile `from` to pile `to`.
* *
@@ -167,6 +188,31 @@ export class SolitaireGame {
const ret = wasm.solitairegame_seed(this.__wbg_ptr); const ret = wasm.solitairegame_seed(this.__wbg_ptr);
return ret; return ret;
} }
/**
* Serialise the full game state as a JSON string for `localStorage`.
*
* Use [`SolitaireGame::from_saved`] to restore it. The returned string is
* opaque — callers should treat it as a blob and store/restore it verbatim.
* @returns {string}
*/
serialize() {
let deferred2_0;
let deferred2_1;
try {
const ret = wasm.solitairegame_serialize(this.__wbg_ptr);
var ptr1 = ret[0];
var len1 = ret[1];
if (ret[3]) {
ptr1 = 0; len1 = 0;
throw takeFromExternrefTable0(ret[2]);
}
deferred2_0 = ptr1;
deferred2_1 = len1;
return getStringFromWasm0(ptr1, len1);
} finally {
wasm.__wbindgen_free(deferred2_0, deferred2_1, 1);
}
}
/** /**
* Full pile snapshot as a JS object. * Full pile snapshot as a JS object.
* *
Binary file not shown.
+8
View File
@@ -241,6 +241,8 @@ pub struct GameSnapshot {
pub move_count: u32, pub move_count: u32,
pub is_won: bool, pub is_won: bool,
pub is_auto_completable: 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, pub undo_count: u32,
/// Number of snapshots currently on the undo stack; 0 means undo is unavailable. /// Number of snapshots currently on the undo stack; 0 means undo is unavailable.
pub undo_stack_len: usize, pub undo_stack_len: usize,
@@ -279,11 +281,17 @@ impl SolitaireGame {
.map(|p| p.cards.iter().map(CardSnapshot::from).collect()) .map(|p| p.cards.iter().map(CardSnapshot::from).collect())
.unwrap_or_default() .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 { GameSnapshot {
score: self.game.score, score: self.game.score,
move_count: self.game.move_count, move_count: self.game.move_count,
is_won: self.game.is_won, is_won: self.game.is_won,
is_auto_completable: self.game.is_auto_completable, is_auto_completable: self.game.is_auto_completable,
has_moves,
undo_count: self.game.undo_count, undo_count: self.game.undo_count,
undo_stack_len: self.game.undo_stack_len(), undo_stack_len: self.game.undo_stack_len(),
stock: cards(PileType::Stock), stock: cards(PileType::Stock),