Compare commits

...

4 Commits

Author SHA1 Message Date
funman300 1cdb78caf2 chore: cargo fmt across workspace; add analytics domain to CSP
Build and Deploy / build-and-push (push) Successful in 4m46s
- Apply cargo fmt to solitaire_engine, solitaire_server formatting.
- solitaire_server/src/lib.rs: add https://analytics.aleshym.co to
  script-src, img-src, and connect-src so the analytics beacon loads
  without a CSP violation.
- docs and README updates.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 12:21:32 -07:00
funman300 baf524ec75 fix(web): rebuild Bevy canvas WASM; add SolitaireGame interactive API
Grey screen fix (canvas_bg.wasm):
- Rebuilt Bevy WASM from refactored solitaire_core that removes the
  per-game KlondikeAdapter field from GameState. The old binary was
  built with wasm-opt -Oz; the large adapter allocation pattern appears
  to trigger an over-aggressive wasm-opt optimisation that corrupts
  Bevy's render pipeline, causing a permanent grey screen on /play.
- build_wasm.sh: change wasm-opt -Oz → -O2. Speed-optimised level avoids
  the size-focused transforms that miscompile Bevy's deep render stacks.

solitaire_core refactoring:
- game_state.rs: remove adapter: KlondikeAdapter field; use static
  KlondikeAdapter::config_for() instead of a per-instance allocation.
  Gate test_pile_state behind #[cfg(feature = "test-support")] so
  production builds carry no test-only heap state.
  Add instruction_history() public accessor (delegates to saved_moves()).
- card.rs: add Card::new(), face_up(), face_down() const constructors
  for more ergonomic test and wasm code.
- pile.rs, solver.rs: cargo fmt.

solitaire_wasm interactive API:
- lib.rs: add SolitaireGame wasm-bindgen struct with draw(), move_cards(),
  undo(), auto_complete_step(), serialize(), from_saved() — the full
  player-action surface used by game.js.
  Add DebugSnapshot, DebugMove, DebugInvariantReport structs and
  debug_snapshot(), debug_legal_moves(), debug_apply_move_json()
  methods for e2e test automation (window.__FERROUS_DEBUG__ bridge).
  Add replay_moves() to export the current game as a Replay v2 payload.
- solitaire_wasm.js + solitaire_wasm_bg.wasm: rebuilt with new API.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 12:21:20 -07:00
funman300 9ff0585454 fix(ci): remove Quaternions registry auth; add canvas WASM drift guard
Dockerfile:
- Drop --mount=type=secret,id=cargo_token: the Quaternions private
  registry has been migrated to the public Cargo.io path so the build
  secret is no longer needed. Removes the requirement for CI_TOKEN to
  carry registry credentials.

CI workflow (docker-build.yml):
- Add solitaire_wasm/** and solitaire_web/** to the push-trigger paths
  so changes to either WASM crate actually fire the build job.
- Add wasm drift check for solitaire_wasm artifacts (solitaire_wasm.js,
  solitaire_wasm_bg.wasm) — exits 1 if solitaire_wasm/ or solitaire_core/
  changed without updating the committed pkg files.
- Add hard canvas drift check: solitaire_web/ changes MUST update
  canvas_bg.wasm or the deploy gets a stale Bevy binary.
- Add advisory notice for solitaire_engine/ / solitaire_core/ changes
  that omit a canvas_bg.wasm rebuild (non-blocking; formatting commits
  should not fail CI).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 12:20:56 -07:00
funman300 64f975ed6d fix(ux): 14 cross-platform UX/UI fixes from 500-game audit
Web client (game.js):
- Restart game timer after undo exits auto-complete sequence
- Pause timer while browser tab is hidden (visibilitychange)
- Validate URL seed — NaN / negative falls back to randomSeed()
- Guard onBoardClick/onBoardDblClick during win (snap.is_won)
- Delay win overlay 320 ms so last card CSS transition finishes
- Force reflow in flashIllegal() to restart shake on rapid re-trigger

Android (safe_area.rs):
- Preserve last-known insets on app resume instead of zeroing them;
  eliminates double layout flash on every foreground cycle

All clients — Bevy engine:
- Radial menu: clamp icon anchors to viewport bounds so icons are
  never placed off-screen on narrow phones
- Auto-complete: deactivate state.active when is_auto_completable
  goes false (undo mid-sequence) to stop perpetual background retry
- Touch selection: gate highlight rebuild on is_changed() — was
  despawning/respawning entities every frame unnecessarily
- Input: fire "Tap a pile to move" InfoToast on first tap in
  TapToSelect mode; document cursor_world 1:1 viewport invariant
- Drag threshold: raise desktop from 4 → 6 px to prevent accidental
  drags from cursor jitter on HiDPI displays

Desktop / Android (solitaire_app):
- Call cleanup_orphaned_tmp_files() at startup to remove .tmp files
  left by crashes between atomic write and rename

Design clarification (klondike_adapter.rs):
- Doc comment: Draw-1 recycling is penalty-only by design (never
  blocked) to avoid creating unwinnable positions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 21:23:52 -07:00
48 changed files with 1783 additions and 701 deletions
+44 -2
View File
@@ -6,6 +6,8 @@ on:
branches: [master]
paths:
- 'solitaire_server/**'
- 'solitaire_wasm/**'
- 'solitaire_web/**'
- 'solitaire_sync/**'
- 'solitaire_core/**'
- 'solitaire_engine/**'
@@ -34,6 +36,48 @@ jobs:
id: meta
run: echo "sha=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT"
- name: Check wasm pkg drift
run: |
set -euo pipefail
BASE_SHA="${{ github.event.before }}"
HEAD_SHA="${{ github.sha }}"
if [ -n "$BASE_SHA" ] && git cat-file -e "$BASE_SHA^{commit}" 2>/dev/null; then
RANGE="$BASE_SHA..$HEAD_SHA"
else
RANGE="HEAD~1..HEAD"
fi
CHANGED="$(git diff --name-only "$RANGE")"
echo "Changed files:"
echo "$CHANGED"
if echo "$CHANGED" | grep -Eq '^(solitaire_wasm/|solitaire_core/|Cargo\.toml|Cargo\.lock)$|^(solitaire_wasm/|solitaire_core/)'; then
if ! echo "$CHANGED" | grep -Eq '^solitaire_server/web/pkg/solitaire_wasm\.js$|^solitaire_server/web/pkg/solitaire_wasm_bg\.wasm$'; then
echo "error: wasm/core/Cargo changed but committed web pkg artifacts are missing."
echo "Run: wasm-pack build --target web --out-dir solitaire_server/web/pkg --no-typescript solitaire_wasm"
exit 1
fi
fi
# Hard check: solitaire_web/ is the direct Bevy WASM source — any
# change there MUST rebuild canvas_bg.wasm or the binary goes stale.
if echo "$CHANGED" | grep -Eq '^solitaire_web/'; then
if ! echo "$CHANGED" | grep -Eq '^solitaire_server/web/pkg/canvas_bg\.wasm$'; then
echo "error: solitaire_web/ changed but canvas_bg.wasm not updated."
echo "Run: ./build_wasm.sh (requires wasm-bindgen-cli + wasm32-unknown-unknown target)"
exit 1
fi
fi
# Advisory notice: solitaire_engine/ and solitaire_core/ changes often
# require a Bevy WASM rebuild but are not enforced (formatting-only
# commits should not be blocked).
if echo "$CHANGED" | grep -Eq '^(solitaire_engine/|solitaire_core/)' && \
! echo "$CHANGED" | grep -Eq '^solitaire_server/web/pkg/canvas_bg\.wasm$'; then
echo "notice: solitaire_engine/core changed without a canvas_bg.wasm rebuild."
echo " If the change affects gameplay run ./build_wasm.sh before pushing."
fi
- name: Log in to Gitea registry
uses: docker/login-action@v3
with:
@@ -57,8 +101,6 @@ jobs:
${{ env.IMAGE }}:latest
cache-from: type=registry,ref=${{ env.IMAGE }}:buildcache
cache-to: type=registry,ref=${{ env.IMAGE }}:buildcache,mode=max
secrets: |
cargo_token=${{ secrets.CI_TOKEN }}
- name: Install kustomize
run: |
+5
View File
@@ -15,6 +15,11 @@ agentdb.rvf.lock
# IDE project files
.idea/
# Browser e2e harness artifacts
solitaire_server/e2e/node_modules/
solitaire_server/e2e/playwright-report/
solitaire_server/e2e/test-results/
# Android signing keystores — never commit
*.jks
*.jks.bak
+20
View File
@@ -118,8 +118,28 @@ cargo test -p solitaire_core -p solitaire_sync -p solitaire_data -p solitaire_se
# Lint
cargo clippy --workspace --all-targets -- -D warnings
# Browser e2e smoke (starts solitaire_server automatically)
cd solitaire_server/e2e
npm ci
npx playwright install chromium
npm test
# Seed-batch cycle regression gate (thresholded)
npm run review:cycles:regression
# Loop-aware candidate benchmark (writes test-results/cycle-candidate.json)
npm run review:cycles:candidate
```
For layered engine-vs-UI automation design (Rust unit tests, wasm debug-API
integration tests, and Playwright UI validation), see
[docs/testing-architecture.md](docs/testing-architecture.md).
For Quaternions (`klondike` / `card_game`) dependency upgrades, use
[`scripts/update_quaternions_deps.sh`](scripts/update_quaternions_deps.sh) and
the runbook in [docs/card-game-integration.md](docs/card-game-integration.md).
## Credits
Built on [Bevy](https://bevyengine.org/) and the wider Rust ecosystem
+5 -1
View File
@@ -65,7 +65,11 @@ wasm-bindgen \
# wasm-opt passes are skipped silently when the tool is not installed.
if command -v wasm-opt &> /dev/null; then
echo "Running wasm-opt on canvas_bg.wasm..."
wasm-opt -Oz \
# Use -O2 (not -Oz): Bevy's render pipeline uses deep call stacks and
# complex memory patterns that wasm-opt -Oz can miscompile, resulting
# in a grey screen on first load. -O2 is speed-optimised and avoids
# the size-focused transforms that trigger the regression.
wasm-opt -O2 \
-o "$OUT_DIR/canvas_bg.wasm" \
"$OUT_DIR/canvas_bg.wasm"
else
+28
View File
@@ -101,6 +101,11 @@ Our 767-line `solitaire_core::solver` reimplements the full game rules to run th
### 5. JSON Serialisation / Persistence
`solitaire_core::GameState` serialises the full mid-game state to JSON via `serde` so the engine can save on exit and restore on launch. `KlondikeState` derives `Clone` + `Eq` + `Hash` but not `Serialize` / `Deserialize`. No upstream changes are needed — this is handled externally.
**Current verification (2026-06-01):** `klondike v0.3.0` and `card_game v0.4.0`
crate manifests expose no `serde` dependency/feature, and source exports no
serde derives for instruction/state snapshot types. Keep Ferrous'
`SavedInstruction` bridge in place.
**Session history:** `StateSnapshot<G>` stores the pre-move game state and instruction. On load, the session is reconstructed from the serialised snapshot history — no full replay from seed needed.
**In our wrapper:** Serialise the `solitaire_core` wrapper struct using newtypes. Define `SavedInstruction` (a `Serialize + Deserialize` mirror of `KlondikeInstruction`) and `SavedStateSnapshot`. Reconstruct `SessionState` from the deserialised history. Schema version field lives on our wrapper.
@@ -147,6 +152,29 @@ Steps in dependency order. Upstream issues #10, #11, and the solver are all merg
---
## Quaternions Upgrade Runbook
Use this sequence whenever upgrading `klondike` / `card_game` from the
Quaternions registry:
1. Review upstream changes/releases:
- <https://git.aleshym.co/Quaternions/card_game>
- <https://git.aleshym.co/Quaternions/klondike>
2. Run:
```bash
scripts/update_quaternions_deps.sh <klondike_version> <card_game_version>
```
3. If the script passes, inspect the resulting `Cargo.lock` diff and land the
upgrade with the normal PR flow.
The script enforces:
- lockfile update to requested versions
- `cargo test --workspace`
- `cargo clippy --workspace -- -D warnings`
- deterministic replay/debug-API smoke tests in `solitaire_wasm`
---
## What Does NOT Need to Change
- The `solitaire_engine` Bevy layer — it works against `solitaire_core` types; changes are isolated to `solitaire_core`.
+10 -1
View File
@@ -25,7 +25,10 @@ use bevy::window::{MonitorSelection, PresentMode, WindowPosition};
use bevy::winit::WinitWindows;
#[cfg(target_os = "android")]
use bevy::winit::{UpdateMode, WinitSettings};
use solitaire_data::{Settings, load_settings_from, provider_for_backend, settings_file_path};
use solitaire_data::{
Settings, cleanup_orphaned_tmp_files, load_settings_from, provider_for_backend,
settings_file_path,
};
use solitaire_engine::{CoreGamePlugin, SyncProvider, register_theme_asset_sources};
fn load_settings() -> Settings {
@@ -49,6 +52,12 @@ pub fn run() {
// and any debugger attached still sees the panic).
install_crash_log_hook();
// Remove any *.tmp files left behind by a crash between an atomic write
// and its rename. Safe to call unconditionally — missing data dir is a
// no-op. Must run before GamePlugin loads saved state so orphaned files
// don't accumulate across launches.
let _ = cleanup_orphaned_tmp_files();
// Initialise the platform keyring store before any token operations.
// On Linux this uses the Secret Service (GNOME Keyring / KWallet); on
// macOS it uses the Keychain; on Windows it uses the Credential store.
+4
View File
@@ -4,6 +4,10 @@ version.workspace = true
license.workspace = true
edition.workspace = true
[features]
default = []
test-support = []
[dependencies]
serde = { workspace = true }
thiserror = { workspace = true }
+37
View File
@@ -111,6 +111,28 @@ pub struct Card {
pub face_up: bool,
}
impl Card {
/// Creates a card with explicit face orientation.
pub const fn new(id: u32, suit: Suit, rank: Rank, face_up: bool) -> Self {
Self {
id,
suit,
rank,
face_up,
}
}
/// Creates a face-up card.
pub const fn face_up(id: u32, suit: Suit, rank: Rank) -> Self {
Self::new(id, suit, rank, true)
}
/// Creates a face-down card.
pub const fn face_down(id: u32, suit: Suit, rank: Rank) -> Self {
Self::new(id, suit, rank, false)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -166,4 +188,19 @@ mod tests {
assert!(Suit::Diamonds.is_red() && Suit::Hearts.is_red());
assert!(Suit::Clubs.is_black() && Suit::Spades.is_black());
}
#[test]
fn card_constructors_set_fields() {
let up = Card::face_up(10, Suit::Spades, Rank::Queen);
assert_eq!(up.id, 10);
assert_eq!(up.suit, Suit::Spades);
assert_eq!(up.rank, Rank::Queen);
assert!(up.face_up);
let down = Card::face_down(11, Suit::Diamonds, Rank::King);
assert_eq!(down.id, 11);
assert_eq!(down.suit, Suit::Diamonds);
assert_eq!(down.rank, Rank::King);
assert!(!down.face_up);
}
}
+107 -132
View File
@@ -1,6 +1,11 @@
use crate::card::{Card, Rank};
use crate::card::Card;
use crate::error::MoveError;
use crate::klondike_adapter::{card_from_kl, compute_time_bonus as scoring_time_bonus, KlondikeAdapter, SavedInstruction};
use crate::klondike_adapter::{
KlondikeAdapter, SavedInstruction, card_from_kl, compute_time_bonus as scoring_time_bonus,
foundation_from_slot as adapter_foundation_from_slot,
skip_cards_from_count as adapter_skip_cards_from_count,
tableau_from_index as adapter_tableau_from_index,
};
use card_game::{Game, Session, SessionConfig};
use klondike::{
DstFoundation, DstTableau, Foundation, Klondike, KlondikeConfig, KlondikeInstruction,
@@ -97,6 +102,7 @@ struct PersistedGameState {
pub saved_moves: Vec<SavedInstruction>,
}
#[cfg(feature = "test-support")]
/// Test-only override state that shadows the real session pile data.
///
/// When `test_pile_state` on `GameState` is `Some`, every pile read method
@@ -143,8 +149,8 @@ pub struct GameState {
pub take_from_foundation: bool,
/// Save-file schema version.
pub schema_version: u32,
pub adapter: KlondikeAdapter,
pub(crate) session: Session<Klondike>,
#[cfg(feature = "test-support")]
/// Test pile overrides. Always `None` in production runtime code.
pub test_pile_state: Option<TestPileState>,
}
@@ -165,9 +171,8 @@ impl PartialEq for GameState {
&& self.schema_version == other.schema_version
&& self.stock_cards() == other.stock_cards()
&& self.waste_cards() == other.waste_cards()
&& (0..4_u8).all(|slot| {
self.foundation_cards(slot).ok() == other.foundation_cards(slot).ok()
})
&& (0..4_u8)
.all(|slot| self.foundation_cards(slot).ok() == other.foundation_cards(slot).ok())
&& (0..7_usize).all(|index| {
let Ok(tableau) = Self::tableau_from_index(index) else {
return false;
@@ -221,15 +226,15 @@ impl<'de> Deserialize<'de> for GameState {
recycle_count: persisted.recycle_count,
take_from_foundation: persisted.take_from_foundation,
schema_version: persisted.schema_version,
adapter: KlondikeAdapter::new(persisted.draw_mode, persisted.take_from_foundation),
session: Self::new_session(persisted.seed, persisted.draw_mode),
#[cfg(feature = "test-support")]
test_pile_state: None,
};
let replay_config = Self::replay_config(game.draw_mode);
for saved in persisted.saved_moves {
let instruction = KlondikeInstruction::try_from(saved)
.map_err(serde::de::Error::custom)?;
let instruction =
KlondikeInstruction::try_from(saved).map_err(serde::de::Error::custom)?;
if !game
.session
.state()
@@ -271,8 +276,8 @@ impl GameState {
recycle_count: 0,
take_from_foundation: true,
schema_version: GAME_STATE_SCHEMA_VERSION,
adapter: KlondikeAdapter::new(draw_mode, true),
session: Self::new_session(seed, draw_mode),
#[cfg(feature = "test-support")]
test_pile_state: None,
}
}
@@ -290,15 +295,11 @@ impl GameState {
}
fn replay_config(draw_mode: DrawMode) -> KlondikeConfig {
KlondikeAdapter::new(draw_mode, true)
.klondike_config()
.clone()
KlondikeAdapter::config_for(draw_mode, true)
}
fn validation_config(&self) -> KlondikeConfig {
KlondikeAdapter::new(self.draw_mode, self.take_from_foundation)
.klondike_config()
.clone()
KlondikeAdapter::config_for(self.draw_mode, self.take_from_foundation)
}
fn saved_moves(&self) -> Vec<SavedInstruction> {
@@ -309,6 +310,14 @@ impl GameState {
.collect()
}
/// Returns the deterministic instruction history for the current deal.
///
/// Combined with [`GameState::seed`] and [`GameState::draw_mode`], this
/// sequence is sufficient to replay the game state exactly.
pub fn instruction_history(&self) -> Vec<SavedInstruction> {
self.saved_moves()
}
fn u32_from_len(len: usize) -> u32 {
if len > u32::MAX as usize {
u32::MAX
@@ -332,6 +341,7 @@ impl GameState {
}
pub fn stock_cards(&self) -> Vec<Card> {
#[cfg(feature = "test-support")]
if let Some(ref state) = self.test_pile_state
&& let Some(ref cards) = state.stock
{
@@ -342,6 +352,7 @@ impl GameState {
}
pub fn waste_cards(&self) -> Vec<Card> {
#[cfg(feature = "test-support")]
if let Some(ref state) = self.test_pile_state
&& let Some(ref cards) = state.waste
{
@@ -352,6 +363,7 @@ impl GameState {
}
pub fn pile(&self, pile: KlondikePile) -> Vec<Card> {
#[cfg(feature = "test-support")]
if let Some(ref state) = self.test_pile_state {
match pile {
KlondikePile::Stock => {
@@ -385,11 +397,17 @@ impl GameState {
}
KlondikePile::Tableau(tableau) => {
let mut cards = Self::cards_with_face(
state.tableau_face_down_cards(tableau).iter().map(card_from_kl),
state
.tableau_face_down_cards(tableau)
.iter()
.map(card_from_kl),
false,
);
cards.extend(Self::cards_with_face(
state.tableau_face_up_cards(tableau).iter().map(card_from_kl),
state
.tableau_face_up_cards(tableau)
.iter()
.map(card_from_kl),
true,
));
cards
@@ -398,26 +416,11 @@ impl GameState {
}
pub fn tableau_from_index(index: usize) -> Result<Tableau, MoveError> {
match index {
0 => Ok(Tableau::Tableau1),
1 => Ok(Tableau::Tableau2),
2 => Ok(Tableau::Tableau3),
3 => Ok(Tableau::Tableau4),
4 => Ok(Tableau::Tableau5),
5 => Ok(Tableau::Tableau6),
6 => Ok(Tableau::Tableau7),
_ => Err(MoveError::InvalidSource),
}
adapter_tableau_from_index(index).ok_or(MoveError::InvalidSource)
}
pub fn foundation_from_slot(slot: u8) -> Result<Foundation, MoveError> {
match slot {
0 => Ok(Foundation::Foundation1),
1 => Ok(Foundation::Foundation2),
2 => Ok(Foundation::Foundation3),
3 => Ok(Foundation::Foundation4),
_ => Err(MoveError::InvalidDestination),
}
adapter_foundation_from_slot(slot).ok_or(MoveError::InvalidDestination)
}
pub fn foundation_cards(&self, slot: u8) -> Result<Vec<Card>, MoveError> {
@@ -425,39 +428,65 @@ impl GameState {
Ok(self.pile(KlondikePile::Foundation(foundation)))
}
/// Returns `true` when test-only pile overrides are active.
#[cfg(feature = "test-support")]
pub fn has_test_pile_overrides(&self) -> bool {
self.test_pile_state.is_some()
}
/// Returns `false` in production builds where test pile overrides are absent.
#[cfg(not(feature = "test-support"))]
pub const fn has_test_pile_overrides(&self) -> bool {
false
}
/// Test-support helper: clear all pile overrides so reads come from the
/// underlying klondike session again.
#[cfg(feature = "test-support")]
pub fn clear_test_pile_overrides(&mut self) {
self.test_pile_state = None;
}
/// Test-support helper: override face-down stock cards returned by
/// [`Self::stock_cards`].
#[cfg(feature = "test-support")]
pub fn set_test_stock_cards(&mut self, cards: Vec<Card>) {
let state = self.test_pile_state.get_or_insert_with(TestPileState::default);
let state = self
.test_pile_state
.get_or_insert_with(TestPileState::default);
state.stock = Some(cards);
}
/// Test-support helper: override face-up waste cards returned by
/// [`Self::waste_cards`] / `pile(KlondikePile::Stock)`.
#[cfg(feature = "test-support")]
pub fn set_test_waste_cards(&mut self, cards: Vec<Card>) {
let state = self.test_pile_state.get_or_insert_with(TestPileState::default);
let state = self
.test_pile_state
.get_or_insert_with(TestPileState::default);
state.waste = Some(cards);
}
/// Test-support helper: override cards for a specific tableau column.
#[cfg(feature = "test-support")]
pub fn set_test_tableau_cards(&mut self, tableau: Tableau, cards: Vec<Card>) {
let state = self.test_pile_state.get_or_insert_with(TestPileState::default);
let state = self
.test_pile_state
.get_or_insert_with(TestPileState::default);
state.tableau.insert(tableau, cards);
}
/// Test-support helper: override cards for a specific foundation pile.
#[cfg(feature = "test-support")]
pub fn set_test_foundation_cards(&mut self, foundation: Foundation, cards: Vec<Card>) {
let state = self.test_pile_state.get_or_insert_with(TestPileState::default);
let state = self
.test_pile_state
.get_or_insert_with(TestPileState::default);
state.foundation.insert(foundation, cards);
}
/// Test-support helper: override cards for a specific pile.
#[cfg(feature = "test-support")]
pub fn set_test_pile_cards(&mut self, pile: KlondikePile, cards: Vec<Card>) {
match pile {
KlondikePile::Stock => {
@@ -479,22 +508,8 @@ impl GameState {
}
fn skip_cards_from_usize(skip: usize) -> Result<SkipCards, MoveError> {
match skip {
0 => Ok(SkipCards::Skip0),
1 => Ok(SkipCards::Skip1),
2 => Ok(SkipCards::Skip2),
3 => Ok(SkipCards::Skip3),
4 => Ok(SkipCards::Skip4),
5 => Ok(SkipCards::Skip5),
6 => Ok(SkipCards::Skip6),
7 => Ok(SkipCards::Skip7),
8 => Ok(SkipCards::Skip8),
9 => Ok(SkipCards::Skip9),
10 => Ok(SkipCards::Skip10),
11 => Ok(SkipCards::Skip11),
12 => Ok(SkipCards::Skip12),
_ => Err(MoveError::RuleViolation("invalid tableau card count".into())),
}
adapter_skip_cards_from_count(skip)
.ok_or_else(|| MoveError::RuleViolation("invalid tableau card count".into()))
}
fn will_flip_tableau_source(&self, from: KlondikePile, count: usize) -> bool {
@@ -539,9 +554,8 @@ impl GameState {
}))
}
(KlondikePile::Foundation(_), KlondikePile::Foundation(_)) => Err(
MoveError::RuleViolation(
"cannot move between foundation slots".into(),
)),
MoveError::RuleViolation("cannot move between foundation slots".into()),
),
(KlondikePile::Stock, KlondikePile::Tableau(dst)) => {
if count != 1 {
return Err(MoveError::RuleViolation(
@@ -595,7 +609,9 @@ impl GameState {
) -> Option<(KlondikePile, KlondikePile, usize)> {
let state = self.session.state().state().state();
match instruction {
KlondikeInstruction::RotateStock => None,
KlondikeInstruction::RotateStock => {
Some((KlondikePile::Stock, KlondikePile::Stock, 1))
}
KlondikeInstruction::DstFoundation(dst_foundation) => {
if matches!(dst_foundation.src, KlondikePile::Foundation(_)) {
return None;
@@ -605,12 +621,17 @@ impl GameState {
KlondikePile::Stock => KlondikePile::Stock,
KlondikePile::Foundation(_) => return None,
};
Some((source, KlondikePile::Foundation(dst_foundation.foundation), 1))
Some((
source,
KlondikePile::Foundation(dst_foundation.foundation),
1,
))
}
KlondikeInstruction::DstTableau(dst_tableau) => {
let (source, count) = match dst_tableau.src {
KlondikePileStack::Tableau(tableau_stack) => {
let face_up_count = state.tableau_face_up_cards(tableau_stack.tableau).len();
let face_up_count =
state.tableau_face_up_cards(tableau_stack.tableau).len();
let count = face_up_count.checked_sub(tableau_stack.skip_cards as usize)?;
if count == 0 {
return None;
@@ -633,16 +654,15 @@ impl GameState {
return Err(MoveError::GameAlreadyWon);
}
let stock_empty = self
.stock_cards()
.is_empty();
let stock_empty = self.stock_cards().is_empty();
let waste_empty = self.waste_cards().is_empty();
if stock_empty && waste_empty {
return Err(MoveError::StockEmpty);
}
let recycling = stock_empty && !waste_empty;
self.session.process_instruction(KlondikeInstruction::RotateStock);
self.session
.process_instruction(KlondikeInstruction::RotateStock);
if recycling {
self.recycle_count = self.recycle_count.saturating_add(1);
@@ -692,9 +712,9 @@ impl GameState {
return Err(MoveError::RuleViolation("move violates rules".into()));
}
let score_delta = self.adapter.score_for_move_with_mode(&from, &to, self.mode);
let score_delta = KlondikeAdapter::score_for_move_with_mode(&from, &to, self.mode);
let flip_bonus = if self.will_flip_tableau_source(from, count) {
self.adapter.score_for_flip_with_mode(self.mode)
KlondikeAdapter::score_for_flip_with_mode(self.mode)
} else {
0
};
@@ -743,8 +763,7 @@ impl GameState {
return false;
}
let suit = pile[0].suit;
pile
.iter()
pile.iter()
.enumerate()
.all(|(i, card)| card.suit == suit && card.rank.value() == i as u8 + 1)
}
@@ -779,18 +798,14 @@ impl GameState {
self.session
.state()
.state()
.possible_instructions(&config)
.get_sorted_moves(&config)
.into_iter()
.filter_map(|instruction| self.instruction_to_move(instruction))
.collect()
}
/// Returns `true` when `move_cards(from, to, count)` would currently succeed.
pub fn can_move_cards(
&self,
from: &KlondikePile,
to: &KlondikePile,
count: usize,
) -> bool {
pub fn can_move_cards(&self, from: &KlondikePile, to: &KlondikePile, count: usize) -> bool {
if self.is_won || from == to {
return false;
}
@@ -838,62 +853,21 @@ impl GameState {
return None;
}
let waste = KlondikePile::Stock;
if let Some(slot) = self
.waste_cards()
.last()
.and_then(|card| self.foundation_slot_for(card))
{
return Some((waste, KlondikePile::Foundation(Self::foundation_from_slot(slot).ok()?)));
}
for index in 0..7 {
let tableau = KlondikePile::Tableau(Self::tableau_from_index(index).ok()?);
if let Some(slot) = self
.pile(tableau)
.last()
.and_then(|card| self.foundation_slot_for(card))
{
return Some((tableau, KlondikePile::Foundation(Self::foundation_from_slot(slot).ok()?)));
}
}
None
}
fn can_place_on_foundation_slot(&self, card: &Card, slot: u8) -> bool {
let Ok(pile) = self.foundation_cards(slot) else {
return false;
};
match pile.last() {
Some(top) => top.suit == card.suit && top.rank.checked_add(1) == Some(card.rank),
None => card.rank == Rank::Ace,
}
}
fn foundation_slot_for(&self, card: &Card) -> Option<u8> {
let mut candidate = None;
let mut empty_slot = None;
for slot in 0..4_u8 {
let Ok(pile) = self.foundation_cards(slot) else {
continue;
};
if pile.is_empty() {
if empty_slot.is_none() {
empty_slot = Some(slot);
self.possible_instructions()
.into_iter()
.find_map(|(from, to, count)| {
if count != 1 {
return None;
}
} else if pile.first().map(|c| c.suit) == Some(card.suit) {
candidate = Some(slot);
break;
}
}
let target = candidate.or_else(|| {
if card.rank == Rank::Ace {
empty_slot
} else {
None
}
});
target.filter(|&slot| self.can_place_on_foundation_slot(card, slot))
if matches!(from, KlondikePile::Foundation(_)) {
return None;
}
if matches!(to, KlondikePile::Foundation(_)) {
Some((from, to))
} else {
None
}
})
}
/// Time bonus added to score on win: `700_000 / elapsed_seconds` (0 if elapsed is 0).
@@ -980,7 +954,8 @@ mod tests {
assert!(
game.possible_instructions()
.iter()
.all(|(f, t, _)| !matches!(f, KlondikePile::Foundation(_)) || !matches!(t, KlondikePile::Tableau(_)))
.all(|(f, t, _)| !matches!(f, KlondikePile::Foundation(_))
|| !matches!(t, KlondikePile::Tableau(_)))
);
assert!(game.move_cards(from, to, 1).is_err());
}
+113 -110
View File
@@ -2,10 +2,10 @@
//!
//! # Current scope (integration steps 14)
//!
//! [`KlondikeAdapter`] owns the authoritative [`KlondikeConfig`] and exposes
//! scoring helpers backed by [`ScoringConfig::DEFAULT`] (Windows XP Standard
//! values). [`GameState`] delegates scoring here so that klondike remains the
//! single source of truth for scoring constants.
//! [`KlondikeAdapter`] is a pure helper namespace for:
//! - building [`KlondikeConfig`] from Ferrous settings
//! - translating between local and upstream types
//! - applying Ferrous-specific scoring policy on top of upstream defaults
//!
//! # Not yet implemented
//!
@@ -25,38 +25,16 @@ use crate::game_state::{DrawMode, GameMode};
/// Bridges `solitaire_core` game config and scoring to the upstream `klondike` crate.
///
/// Holds a [`KlondikeConfig`] reflecting the current game settings and exposes
/// scoring helpers that read from [`ScoringConfig::DEFAULT`] (WXP values).
/// [`GameState`] uses this instead of calling `scoring.rs` functions directly.
#[derive(Clone, Debug)]
pub struct KlondikeAdapter {
config: KlondikeConfig,
}
impl PartialEq for KlondikeAdapter {
fn eq(&self, other: &Self) -> bool {
self.config.draw_stock == other.config.draw_stock
&& self.config.move_from_foundation == other.config.move_from_foundation
}
}
impl Eq for KlondikeAdapter {}
impl Default for KlondikeAdapter {
/// Returns an adapter with Draw-1 and `take_from_foundation = true`,
/// matching `GameState`'s own defaults. Used by `#[serde(skip)]`
/// field initialisation on deserialisation.
fn default() -> Self {
Self::new(DrawMode::DrawOne, true)
}
}
/// This type is intentionally zero-sized: it does not carry mutable runtime
/// state, and exists only as a namespace for configuration, conversion, and
/// scoring helpers.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct KlondikeAdapter;
impl KlondikeAdapter {
/// Create an adapter from the game's draw mode and foundation house-rule setting.
///
/// `take_from_foundation = true` maps to [`MoveFromFoundationConfig::Allowed`];
/// `false` maps to [`MoveFromFoundationConfig::Disallowed`].
pub fn new(draw_mode: DrawMode, take_from_foundation: bool) -> Self {
let config = KlondikeConfig {
/// Build a [`KlondikeConfig`] from draw mode and foundation house-rule setting.
pub fn config_for(draw_mode: DrawMode, take_from_foundation: bool) -> KlondikeConfig {
KlondikeConfig {
draw_stock: match draw_mode {
DrawMode::DrawOne => DrawStockConfig::DrawOne,
DrawMode::DrawThree => DrawStockConfig::DrawThree,
@@ -67,24 +45,7 @@ impl KlondikeAdapter {
MoveFromFoundationConfig::Disallowed
},
scoring: ScoringConfig::DEFAULT,
};
Self { config }
}
/// Returns a reference to the underlying [`KlondikeConfig`].
///
/// Used by the solver and pile-mapping code added in later integration steps.
pub fn klondike_config(&self) -> &KlondikeConfig {
&self.config
}
/// Update the foundation house-rule flag, keeping [`KlondikeConfig`] in sync.
pub fn set_take_from_foundation(&mut self, allowed: bool) {
self.config.move_from_foundation = if allowed {
MoveFromFoundationConfig::Allowed
} else {
MoveFromFoundationConfig::Disallowed
};
}
}
// ── Scoring helpers ───────────────────────────────────────────────────
@@ -96,8 +57,8 @@ impl KlondikeAdapter {
/// - Waste → Tableau: +5
/// - Foundation → Tableau: 15
/// - All other moves: 0
pub fn score_for_move(&self, from: &KlondikePile, to: &KlondikePile) -> i32 {
let sc = &self.config.scoring;
pub fn score_for_move(from: &KlondikePile, to: &KlondikePile) -> i32 {
let sc = ScoringConfig::DEFAULT;
match (from, to) {
(_, KlondikePile::Foundation(_)) => sc.move_to_foundation,
(KlondikePile::Stock, KlondikePile::Tableau(_)) => sc.move_to_tableau,
@@ -107,8 +68,8 @@ impl KlondikeAdapter {
}
/// Score delta for exposing a face-down tableau card: +5.
pub fn score_for_flip(&self) -> i32 {
self.config.scoring.flip_up_bonus
pub fn score_for_flip() -> i32 {
ScoringConfig::DEFAULT.flip_up_bonus
}
/// Score delta for undo: 15.
@@ -131,6 +92,12 @@ impl KlondikeAdapter {
/// | Draw-1 | 1 | 100 |
/// | Draw-3 | 3 | 20 |
///
/// **Design note:** recycling is *never* blocked — only penalised.
/// This is intentional: Draw-1 can be played indefinitely with the score
/// dropping toward zero after the first free recycle. A hard cap would
/// create unwinnable positions when the solver cannot find a path without
/// additional recycling. Zen mode suppresses the penalty entirely.
///
/// `recycle_count` must be the new total **after** this recycle.
pub fn score_for_recycle(recycle_count: u32, is_draw_three: bool) -> i32 {
if is_draw_three {
@@ -145,20 +112,23 @@ impl KlondikeAdapter {
/// Score delta for a card move, accounting for game mode.
///
/// Returns 0 in [`GameMode::Zen`] (all scoring suppressed).
pub fn score_for_move_with_mode(
&self,
from: &KlondikePile,
to: &KlondikePile,
mode: GameMode,
) -> i32 {
if mode == GameMode::Zen { 0 } else { self.score_for_move(from, to) }
pub fn score_for_move_with_mode(from: &KlondikePile, to: &KlondikePile, mode: GameMode) -> i32 {
if mode == GameMode::Zen {
0
} else {
Self::score_for_move(from, to)
}
}
/// Score delta for exposing a face-down card, accounting for game mode.
///
/// Returns 0 in [`GameMode::Zen`].
pub fn score_for_flip_with_mode(&self, mode: GameMode) -> i32 {
if mode == GameMode::Zen { 0 } else { self.score_for_flip() }
pub fn score_for_flip_with_mode(mode: GameMode) -> i32 {
if mode == GameMode::Zen {
0
} else {
Self::score_for_flip()
}
}
/// Compute the new score after an undo, accounting for game mode.
@@ -191,13 +161,58 @@ impl KlondikeAdapter {
// ── Type-conversion utilities ─────────────────────────────────────────────
/// Convert a zero-based tableau index (0..=6) into [`Tableau`].
pub fn tableau_from_index(index: usize) -> Option<Tableau> {
match index {
0 => Some(Tableau::Tableau1),
1 => Some(Tableau::Tableau2),
2 => Some(Tableau::Tableau3),
3 => Some(Tableau::Tableau4),
4 => Some(Tableau::Tableau5),
5 => Some(Tableau::Tableau6),
6 => Some(Tableau::Tableau7),
_ => None,
}
}
/// Convert a zero-based foundation slot (0..=3) into [`Foundation`].
pub fn foundation_from_slot(slot: u8) -> Option<Foundation> {
match slot {
0 => Some(Foundation::Foundation1),
1 => Some(Foundation::Foundation2),
2 => Some(Foundation::Foundation3),
3 => Some(Foundation::Foundation4),
_ => None,
}
}
/// Convert a tableau skip count (0..=12) into [`SkipCards`].
pub fn skip_cards_from_count(skip: usize) -> Option<SkipCards> {
match skip {
0 => Some(SkipCards::Skip0),
1 => Some(SkipCards::Skip1),
2 => Some(SkipCards::Skip2),
3 => Some(SkipCards::Skip3),
4 => Some(SkipCards::Skip4),
5 => Some(SkipCards::Skip5),
6 => Some(SkipCards::Skip6),
7 => Some(SkipCards::Skip7),
8 => Some(SkipCards::Skip8),
9 => Some(SkipCards::Skip9),
10 => Some(SkipCards::Skip10),
11 => Some(SkipCards::Skip11),
12 => Some(SkipCards::Skip12),
_ => None,
}
}
/// Convert [`card_game::Suit`] back to our [`crate::card::Suit`].
pub(crate) fn suit_from_kl(suit: KlSuit) -> crate::card::Suit {
match suit {
KlSuit::Clubs => crate::card::Suit::Clubs,
KlSuit::Clubs => crate::card::Suit::Clubs,
KlSuit::Diamonds => crate::card::Suit::Diamonds,
KlSuit::Hearts => crate::card::Suit::Hearts,
KlSuit::Spades => crate::card::Suit::Spades,
KlSuit::Hearts => crate::card::Suit::Hearts,
KlSuit::Spades => crate::card::Suit::Spades,
}
}
@@ -221,7 +236,12 @@ pub fn card_from_kl(card: &KlCard) -> crate::card::Card {
.position(|s| *s == suit)
.expect("suit always in SUITS") as u32;
let id = suit_index * 13 + (rank.value() as u32 - 1);
crate::card::Card { id, suit, rank, face_up: false }
crate::card::Card {
id,
suit,
rank,
face_up: false,
}
}
// ── Serde newtypes for KlondikeInstruction (Step 7) ──────────────────────────
@@ -343,7 +363,10 @@ impl From<KlondikePile> for SavedKlondikePile {
impl From<TableauStack> for SavedTableauStack {
fn from(ts: TableauStack) -> Self {
Self { tableau: ts.tableau.into(), skip_cards: ts.skip_cards.into() }
Self {
tableau: ts.tableau.into(),
skip_cards: ts.skip_cards.into(),
}
}
}
@@ -359,13 +382,19 @@ impl From<KlondikePileStack> for SavedKlondikePileStack {
impl From<DstFoundation> for SavedDstFoundation {
fn from(df: DstFoundation) -> Self {
Self { src: df.src.into(), foundation: df.foundation.into() }
Self {
src: df.src.into(),
foundation: df.foundation.into(),
}
}
}
impl From<DstTableau> for SavedDstTableau {
fn from(dt: DstTableau) -> Self {
Self { src: dt.src.into(), tableau: dt.tableau.into() }
Self {
src: dt.src.into(),
tableau: dt.tableau.into(),
}
}
}
@@ -384,51 +413,21 @@ impl From<KlondikeInstruction> for SavedInstruction {
impl TryFrom<SavedTableau> for Tableau {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedTableau) -> Result<Self, Self::Error> {
match s.0 {
0 => Ok(Tableau::Tableau1),
1 => Ok(Tableau::Tableau2),
2 => Ok(Tableau::Tableau3),
3 => Ok(Tableau::Tableau4),
4 => Ok(Tableau::Tableau5),
5 => Ok(Tableau::Tableau6),
6 => Ok(Tableau::Tableau7),
n => Err(InvalidSavedInstruction::Tableau(n)),
}
tableau_from_index(s.0 as usize).ok_or(InvalidSavedInstruction::Tableau(s.0))
}
}
impl TryFrom<SavedFoundation> for Foundation {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedFoundation) -> Result<Self, Self::Error> {
match s.0 {
0 => Ok(Foundation::Foundation1),
1 => Ok(Foundation::Foundation2),
2 => Ok(Foundation::Foundation3),
3 => Ok(Foundation::Foundation4),
n => Err(InvalidSavedInstruction::Foundation(n)),
}
foundation_from_slot(s.0).ok_or(InvalidSavedInstruction::Foundation(s.0))
}
}
impl TryFrom<SavedSkipCards> for SkipCards {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedSkipCards) -> Result<Self, Self::Error> {
match s.0 {
0 => Ok(SkipCards::Skip0),
1 => Ok(SkipCards::Skip1),
2 => Ok(SkipCards::Skip2),
3 => Ok(SkipCards::Skip3),
4 => Ok(SkipCards::Skip4),
5 => Ok(SkipCards::Skip5),
6 => Ok(SkipCards::Skip6),
7 => Ok(SkipCards::Skip7),
8 => Ok(SkipCards::Skip8),
9 => Ok(SkipCards::Skip9),
10 => Ok(SkipCards::Skip10),
11 => Ok(SkipCards::Skip11),
12 => Ok(SkipCards::Skip12),
n => Err(InvalidSavedInstruction::SkipCards(n)),
}
skip_cards_from_count(s.0 as usize).ok_or(InvalidSavedInstruction::SkipCards(s.0))
}
}
@@ -459,9 +458,7 @@ impl TryFrom<SavedKlondikePileStack> for KlondikePileStack {
Ok(match s {
SavedKlondikePileStack::Tableau(ts) => KlondikePileStack::Tableau(ts.try_into()?),
SavedKlondikePileStack::Stock => KlondikePileStack::Stock,
SavedKlondikePileStack::Foundation(f) => {
KlondikePileStack::Foundation(f.try_into()?)
}
SavedKlondikePileStack::Foundation(f) => KlondikePileStack::Foundation(f.try_into()?),
})
}
}
@@ -469,14 +466,20 @@ impl TryFrom<SavedKlondikePileStack> for KlondikePileStack {
impl TryFrom<SavedDstFoundation> for DstFoundation {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedDstFoundation) -> Result<Self, Self::Error> {
Ok(DstFoundation { src: s.src.try_into()?, foundation: s.foundation.try_into()? })
Ok(DstFoundation {
src: s.src.try_into()?,
foundation: s.foundation.try_into()?,
})
}
}
impl TryFrom<SavedDstTableau> for DstTableau {
type Error = InvalidSavedInstruction;
fn try_from(s: SavedDstTableau) -> Result<Self, Self::Error> {
Ok(DstTableau { src: s.src.try_into()?, tableau: s.tableau.try_into()? })
Ok(DstTableau {
src: s.src.try_into()?,
tableau: s.tableau.try_into()?,
})
}
}
+5 -30
View File
@@ -49,18 +49,8 @@ mod tests {
#[test]
fn pile_top_returns_last_card() {
let mut pile = Pile::new(KlondikePile::Stock);
pile.cards.push(Card {
id: 0,
suit: Suit::Hearts,
rank: Rank::Ace,
face_up: true,
});
pile.cards.push(Card {
id: 1,
suit: Suit::Clubs,
rank: Rank::Two,
face_up: true,
});
pile.cards.push(Card::face_up(0, Suit::Hearts, Rank::Ace));
pile.cards.push(Card::face_up(1, Suit::Clubs, Rank::Two));
assert_eq!(pile.top().unwrap().id, 1);
}
@@ -79,30 +69,15 @@ mod tests {
#[test]
fn claimed_suit_is_none_for_non_foundation() {
let mut pile = Pile::new(KlondikePile::Tableau(klondike::Tableau::Tableau1));
pile.cards.push(Card {
id: 0,
suit: Suit::Hearts,
rank: Rank::Ace,
face_up: true,
});
pile.cards.push(Card::face_up(0, Suit::Hearts, Rank::Ace));
assert!(pile.claimed_suit().is_none());
}
#[test]
fn claimed_suit_returns_bottom_card_suit() {
let mut pile = Pile::new(KlondikePile::Foundation(klondike::Foundation::Foundation3));
pile.cards.push(Card {
id: 0,
suit: Suit::Hearts,
rank: Rank::Ace,
face_up: true,
});
pile.cards.push(Card {
id: 1,
suit: Suit::Hearts,
rank: Rank::Two,
face_up: true,
});
pile.cards.push(Card::face_up(0, Suit::Hearts, Rank::Ace));
pile.cards.push(Card::face_up(1, Suit::Hearts, Rank::Two));
assert_eq!(pile.claimed_suit(), Some(Suit::Hearts));
}
}
+71 -168
View File
@@ -1,14 +1,13 @@
//! Klondike solvability checker using deterministic DFS over [`GameState`].
//! Klondike solvability checker using upstream `card_game::Session::solve()`.
//!
//! Used by the engine to back the **Settings → Gameplay → "Winnable deals only"**
//! toggle and by the hint system when it wants the first move on a winning path.
use std::collections::HashSet;
use card_game::{Session, SessionConfig, SolveError, StateSnapshot};
use klondike::{Klondike, KlondikeInstruction, KlondikePile, KlondikePileStack};
use klondike::{Foundation, KlondikePile, Tableau};
use crate::card::Card;
use crate::game_state::{DifficultyLevel, DrawMode, GameMode, GameState};
use crate::game_state::{DrawMode, GameState};
use crate::klondike_adapter::KlondikeAdapter;
/// Verdict returned by [`try_solve`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -59,14 +58,6 @@ pub struct SolveOutcome {
pub first_move: Option<SolverMove>,
}
#[derive(Debug, Clone)]
struct DfsFrame {
state: GameState,
moves: Vec<SolverMove>,
next_index: usize,
first_move: Option<SolverMove>,
}
/// Tries to solve a fresh Classic-mode game from `seed` + `draw_mode`.
pub fn try_solve(seed: u64, draw_mode: DrawMode, config: &SolverConfig) -> SolverResult {
try_solve_with_first_move(seed, draw_mode, config).result
@@ -105,6 +96,7 @@ fn solve_game_state(initial: &GameState, config: &SolverConfig) -> SolveOutcome
first_move: None,
};
}
// Preserve the historical payload contract: winnable verdicts always carry
// a first move. An already-won state therefore returns no recommendation.
if initial.is_won {
@@ -114,174 +106,85 @@ fn solve_game_state(initial: &GameState, config: &SolverConfig) -> SolveOutcome
};
}
let mut visited: HashSet<Vec<u32>> = HashSet::with_capacity(effective_state_budget.min(16_384));
visited.insert(state_key(initial));
let solver_config = SessionConfig {
inner: KlondikeAdapter::config_for(initial.draw_mode, initial.take_from_foundation),
undo_penalty: 0,
solve_moves_budget: effective_move_budget,
solve_states_budget: effective_state_budget as u64,
};
let solver_session = Session::new(initial.session.state().state().clone(), solver_config);
let mut states_visited: usize = 1;
let mut moves_considered: u64 = 0;
let mut saw_inconclusive = false;
let mut stack = vec![DfsFrame {
state: initial.clone(),
moves: candidate_moves(initial),
next_index: 0,
first_move: None,
}];
while let Some(frame) = stack.last_mut() {
if frame.state.is_won {
if let Some(first_move) = frame.first_move.clone() {
return SolveOutcome {
match solver_session.solve() {
Ok(Some(solution)) => {
let first_move = solution
.raw_solution()
.iter()
.find_map(snapshot_to_solver_move);
if let Some(first_move) = first_move {
SolveOutcome {
result: SolverResult::Winnable,
first_move: Some(first_move),
};
}
} else {
SolveOutcome {
result: SolverResult::Inconclusive,
first_move: None,
}
}
stack.pop();
continue;
}
if frame.next_index >= frame.moves.len() {
stack.pop();
continue;
}
if moves_considered >= effective_move_budget {
saw_inconclusive = true;
break;
}
let next_move = frame.moves[frame.next_index].clone();
frame.next_index += 1;
moves_considered = moves_considered.saturating_add(1);
let Some(next_state) = apply_solver_move(&frame.state, &next_move) else {
continue;
};
let key = state_key(&next_state);
if visited.contains(&key) {
continue;
}
if states_visited >= effective_state_budget {
saw_inconclusive = true;
continue;
}
visited.insert(key);
states_visited = states_visited.saturating_add(1);
let first_move = frame
.first_move
.clone()
.or_else(|| Some(next_move.clone()));
let child_moves = candidate_moves(&next_state);
stack.push(DfsFrame {
state: next_state,
moves: child_moves,
next_index: 0,
first_move,
});
}
if saw_inconclusive {
SolveOutcome {
result: SolverResult::Inconclusive,
first_move: None,
}
} else {
SolveOutcome {
Ok(None) => SolveOutcome {
result: SolverResult::Unwinnable,
first_move: None,
}
},
Err(SolveError::MovesBudgetExceeded | SolveError::StatesBudgetExceeded) => SolveOutcome {
result: SolverResult::Inconclusive,
first_move: None,
},
}
}
fn candidate_moves(game: &GameState) -> Vec<SolverMove> {
let mut out: Vec<SolverMove> = game
.possible_instructions()
.into_iter()
.map(|(source, dest, count)| SolverMove {
source,
dest,
count,
})
.collect();
if !game.stock_cards().is_empty() || !game.waste_cards().is_empty() {
out.push(SolverMove {
fn snapshot_to_solver_move(snapshot: &StateSnapshot<Klondike>) -> Option<SolverMove> {
let source_state = snapshot.state().state();
match *snapshot.instruction() {
KlondikeInstruction::RotateStock => Some(SolverMove {
source: KlondikePile::Stock,
dest: KlondikePile::Stock,
count: 1,
});
}
}),
KlondikeInstruction::DstFoundation(dst_foundation) => {
let source = match dst_foundation.src {
KlondikePile::Tableau(tableau) => KlondikePile::Tableau(tableau),
KlondikePile::Stock => KlondikePile::Stock,
KlondikePile::Foundation(_) => return None,
};
Some(SolverMove {
source,
dest: KlondikePile::Foundation(dst_foundation.foundation),
count: 1,
})
}
KlondikeInstruction::DstTableau(dst_tableau) => {
let (source, count) = match dst_tableau.src {
KlondikePileStack::Tableau(tableau_stack) => {
let face_up_count = source_state.tableau_face_up_cards(tableau_stack.tableau).len();
let count = face_up_count.checked_sub(tableau_stack.skip_cards as usize)?;
if count == 0 {
return None;
}
(KlondikePile::Tableau(tableau_stack.tableau), count)
}
KlondikePileStack::Stock => (KlondikePile::Stock, 1),
KlondikePileStack::Foundation(foundation) => {
(KlondikePile::Foundation(foundation), 1)
}
};
out
}
fn apply_solver_move(game: &GameState, mv: &SolverMove) -> Option<GameState> {
let mut next = game.clone();
if mv.source == KlondikePile::Stock && mv.dest == KlondikePile::Stock {
next.draw().ok()?;
} else {
next.move_cards(mv.source, mv.dest, mv.count).ok()?;
}
Some(next)
}
fn state_key(game: &GameState) -> Vec<u32> {
let mut key = Vec::with_capacity(96);
append_pile_key(&game.stock_cards(), &mut key);
append_pile_key(&game.waste_cards(), &mut key);
for foundation in [
Foundation::Foundation1,
Foundation::Foundation2,
Foundation::Foundation3,
Foundation::Foundation4,
] {
append_pile_key(&game.pile(KlondikePile::Foundation(foundation)), &mut key);
}
for tableau in [
Tableau::Tableau1,
Tableau::Tableau2,
Tableau::Tableau3,
Tableau::Tableau4,
Tableau::Tableau5,
Tableau::Tableau6,
Tableau::Tableau7,
] {
append_pile_key(&game.pile(KlondikePile::Tableau(tableau)), &mut key);
}
key.push(game.draw_mode as u32);
key.push(mode_key(game.mode));
key.push(u32::from(game.take_from_foundation));
key
}
fn append_pile_key(cards: &[Card], key: &mut Vec<u32>) {
key.push(cards.len() as u32);
for card in cards {
key.push((card.id << 1) | u32::from(card.face_up));
}
}
fn mode_key(mode: GameMode) -> u32 {
match mode {
GameMode::Classic => 0,
GameMode::Zen => 1,
GameMode::Challenge => 2,
GameMode::TimeAttack => 3,
GameMode::Difficulty(level) => match level {
DifficultyLevel::Easy => 10,
DifficultyLevel::Medium => 11,
DifficultyLevel::Hard => 12,
DifficultyLevel::Expert => 13,
DifficultyLevel::Grandmaster => 14,
DifficultyLevel::Random => 15,
},
Some(SolverMove {
source,
dest: KlondikePile::Tableau(dst_tableau.tableau),
count,
})
}
}
}
+1 -1
View File
@@ -3,10 +3,10 @@
//! All saves go through `filename.json.tmp` → `rename()` so a crash or power
//! loss during a write never corrupts the saved data.
use chrono::Utc;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use solitaire_core::game_state::{GAME_STATE_SCHEMA_VERSION, GameState};
+1 -1
View File
@@ -12,9 +12,9 @@
//! without matching on [`SyncBackend`] anywhere else in the codebase.
use async_trait::async_trait;
use solitaire_sync::{SyncPayload, SyncResponse};
#[cfg(not(target_arch = "wasm32"))]
use solitaire_sync::{ChallengeGoal, LeaderboardEntry};
use solitaire_sync::{SyncPayload, SyncResponse};
use crate::{SyncError, SyncProvider};
+1
View File
@@ -52,3 +52,4 @@ web-sys = { version = "0.3", features = ["Storage", "Window"] }
[dev-dependencies]
async-trait = { workspace = true }
tempfile = { workspace = true }
solitaire_core = { workspace = true, features = ["test-support"] }
+1 -3
View File
@@ -373,9 +373,7 @@ fn play_on_draw(
// When the stock pile is empty the draw action recycles the waste pile
// back to stock. Play the flip sound at half volume to give audible
// feedback that distinguishes a recycle from a normal draw.
let stock_len = game
.as_ref()
.map_or(1, |g| g.0.stock_cards().len()); // default > 0 → normal draw sound
let stock_len = game.as_ref().map_or(1, |g| g.0.stock_cards().len()); // default > 0 → normal draw sound
if is_recycle(stock_len) {
let mut data = lib.flip.clone();
+24 -19
View File
@@ -51,15 +51,15 @@ impl Plugin for AutoCompletePlugin {
app.init_resource::<AutoCompleteState>()
.add_message::<RequestRedraw>()
.add_systems(
Update,
(
detect_auto_complete,
on_auto_complete_start,
drive_auto_complete,
)
.chain()
.after(GameMutation),
);
Update,
(
detect_auto_complete,
on_auto_complete_start,
drive_auto_complete,
)
.chain()
.after(GameMutation),
);
}
}
@@ -83,14 +83,21 @@ fn detect_auto_complete(
if game.0.is_auto_completable && !state.active {
state.active = true;
state.cooldown = AUTO_COMPLETE_INITIAL_DELAY;
} else if !game.0.is_auto_completable && state.active {
// `is_auto_completable` only becomes false after an explicit undo
// (which puts a card back on the tableau or re-fills the stock/waste)
// or a new-game reset — never as a transient gap during a normal
// auto-complete sequence. Deactivate here so `drive_auto_complete`
// does not keep retrying indefinitely after the player undoes out of
// the sequence.
//
// Note: the transient-`None` case mentioned in older versions of this
// comment referred to `next_auto_complete_move()` returning `None`, not
// to `is_auto_completable` being false. Those are independent fields;
// `drive_auto_complete` still retries on a transient `None` return from
// `next_auto_complete_move` because that check happens there, not here.
state.active = false;
}
// Intentionally no `else if !is_auto_completable` branch here.
// Deactivating on every frame where `is_auto_completable` is false
// would hard-stop the sequence mid-flight whenever `next_auto_complete_move`
// transiently returns `None` (e.g. while the previous move is still
// in-flight). The `is_won` check above already handles the definitive
// end-of-game case; `drive_auto_complete` simply retries next tick
// when no move is available yet.
}
/// Plays a distinct chime the moment auto-complete first activates.
@@ -244,9 +251,7 @@ mod tests {
// Zero out the cooldown so drive fires on the next update regardless
// of the initial delay constant.
app.world_mut()
.resource_mut::<AutoCompleteState>()
.cooldown = 0.0;
app.world_mut().resource_mut::<AutoCompleteState>().cooldown = 0.0;
app.update(); // drive fires the move
let events = app.world().resource::<Messages<MoveRequestEvent>>();
@@ -100,7 +100,7 @@ impl AnimationTuning {
platform: InputPlatform::Mouse,
duration_scale: 1.0,
overshoot_scale: 1.0,
drag_threshold_px: 4.0,
drag_threshold_px: 6.0,
drag_scale: 1.08,
hover_scale: 1.04,
hover_lerp_speed: 14.0,
+13 -16
View File
@@ -16,10 +16,9 @@ use bevy::color::Color;
use bevy::prelude::*;
use bevy::sprite::Anchor;
use bevy::window::WindowResized;
use klondike::{Foundation, KlondikePile, Tableau};
use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::game_state::{DrawMode, GameState};
use klondike::{Foundation, KlondikePile, Tableau};
use crate::animation_plugin::{CARD_ANIM_Z_LIFT, CardAnim, EffectiveSlideDuration};
use crate::card_animation::CardAnimation;
@@ -2355,16 +2354,16 @@ fn update_tableau_fan_frac(
Tableau::Tableau6,
Tableau::Tableau7,
]
.into_iter()
.map(|tableau| {
game.0
.pile(klondike::KlondikePile::Tableau(tableau))
.into_iter()
.filter(|c| c.face_up)
.count()
})
.max()
.unwrap_or(0);
.into_iter()
.map(|tableau| {
game.0
.pile(klondike::KlondikePile::Tableau(tableau))
.into_iter()
.filter(|c| c.face_up)
.count()
})
.max()
.unwrap_or(0);
let card_h = layout.0.card_size.y;
let avail = layout.0.available_tableau_height;
@@ -2575,8 +2574,7 @@ mod tests {
"need at least 3 waste cards for this test"
);
let waste_ids: std::collections::HashSet<u32> =
waste_pile.iter().map(|c| c.id).collect();
let waste_ids: std::collections::HashSet<u32> = waste_pile.iter().map(|c| c.id).collect();
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout);
@@ -2628,8 +2626,7 @@ mod tests {
let count = waste_pile.len();
assert!(count >= 2, "need at least 2 waste cards");
let waste_ids: std::collections::HashSet<u32> =
waste_pile.iter().map(|c| c.id).collect();
let waste_ids: std::collections::HashSet<u32> = waste_pile.iter().map(|c| c.id).collect();
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout);
+7 -4
View File
@@ -331,7 +331,11 @@ fn update_drop_target_overlays(
/// for everything else it is card-sized. Replicated here rather than
/// imported because `pile_drop_rect` is private to `input_plugin` and
/// this overlay is the only other consumer.
fn drop_overlay_rect(pile: &KlondikePile, layout: &Layout, game: &GameState) -> Option<(Vec2, Vec2)> {
fn drop_overlay_rect(
pile: &KlondikePile,
layout: &Layout,
game: &GameState,
) -> Option<(Vec2, Vec2)> {
let centre = layout.pile_positions.get(pile).copied()?;
if matches!(pile, KlondikePile::Tableau(_)) {
let card_count = game.pile(*pile).len();
@@ -619,7 +623,7 @@ mod tests {
drag.committed = true;
}
#[test]
#[test]
fn drop_target_overlay_does_not_spawn_for_invalid_destination() {
// 5 of Spades (black) onto Tableau(2)'s 6 of Clubs (also black)
// — same colour family, illegal. Tableau(2) must NOT be
@@ -658,5 +662,4 @@ mod tests {
"Tableau(2) must not be highlighted for an illegal drop, got {overlays:?}"
);
}
}
}
@@ -13,10 +13,10 @@
use bevy::input::ButtonInput;
use bevy::prelude::*;
use chrono::{DateTime, Duration, Local, NaiveDate, Utc};
use solitaire_data::{daily_seed_for, save_progress_to};
#[cfg(not(target_arch = "wasm32"))]
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use chrono::{DateTime, Duration, Local, NaiveDate, Utc};
use solitaire_data::{daily_seed_for, save_progress_to};
#[cfg(not(target_arch = "wasm32"))]
use solitaire_sync::ChallengeGoal;
@@ -354,7 +354,6 @@ fn check_date_rollover(
}
}
#[cfg(test)]
#[allow(dead_code)]
mod tests {
+1 -1
View File
@@ -849,8 +849,8 @@ mod tests {
#[test]
fn shake_anim_skipped_under_reduce_motion() {
use bevy::ecs::message::Messages;
use solitaire_core::game_state::{DrawMode, GameState};
use klondike::Tableau;
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_data::Settings;
let mut app = App::new();
+66 -48
View File
@@ -13,8 +13,8 @@ use chrono::Utc;
use bevy::prelude::*;
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use bevy::window::AppLifecycle;
use solitaire_core::game_state::{DrawMode, GameMode, GameState};
use klondike::KlondikePile;
use solitaire_core::game_state::{DrawMode, GameMode, GameState};
use solitaire_core::solver::{SolverConfig, SolverResult, try_solve};
#[allow(deprecated)]
use solitaire_data::latest_replay_path;
@@ -521,10 +521,7 @@ fn handle_new_game(
// hides that information and reads naturally as "dealt from the
// deck." Skipped when LayoutResource isn't present (headless tests).
if let Some(layout) = layout.as_ref()
&& let Some(stock) = layout
.0
.pile_positions
.get(&klondike::KlondikePile::Stock)
&& let Some(stock) = layout.0.pile_positions.get(&klondike::KlondikePile::Stock)
{
for mut tx in &mut card_transforms {
tx.translation.x = stock.x;
@@ -1047,17 +1044,11 @@ fn foundation_slot(foundation: klondike::Foundation) -> Option<u8> {
/// 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 {
// Drawing from a non-empty stock, and recycling a non-empty waste back to
// stock, are always legal moves in standard Klondike (unlimited recycles).
// A game can only be genuinely stuck when both stock AND waste are exhausted.
let stock_empty = game
.stock_cards()
.is_empty();
let waste_empty = game
.waste_cards()
.is_empty();
let stock_empty = game.stock_cards().is_empty();
let waste_empty = game.waste_cards().is_empty();
if !stock_empty || !waste_empty {
return true;
}
@@ -1191,7 +1182,10 @@ fn handle_game_over_input(
if keys.just_pressed(KeyCode::KeyN) || keys.just_pressed(KeyCode::Escape) {
// confirmed: true — the game is already stuck; no abandon-confirmation needed.
new_game.write(NewGameRequestEvent { confirmed: true, ..default() });
new_game.write(NewGameRequestEvent {
confirmed: true,
..default()
});
} else if keys.just_pressed(KeyCode::KeyU) {
for entity in &screens {
commands.entity(entity).despawn();
@@ -1219,7 +1213,10 @@ fn handle_game_over_button_input(
}
if new_game_buttons.iter().any(|i| *i == Interaction::Pressed) {
// confirmed: true — the game is already stuck; no abandon-confirmation needed.
new_game.write(NewGameRequestEvent { confirmed: true, ..default() });
new_game.write(NewGameRequestEvent {
confirmed: true,
..default()
});
} else if undo_buttons.iter().any(|i| *i == Interaction::Pressed) {
for entity in &screens {
commands.entity(entity).despawn();
@@ -1388,9 +1385,11 @@ mod tests {
#[test]
fn new_game_request_reseeds() {
let mut app = test_app(1);
let before: Vec<u32> = app.world().resource::<GameStateResource>().0.pile(KlondikePile::Tableau(
Tableau::Tableau1,
))
let before: Vec<u32> = app
.world()
.resource::<GameStateResource>()
.0
.pile(KlondikePile::Tableau(Tableau::Tableau1))
.iter()
.map(|c| c.id)
.collect();
@@ -1402,9 +1401,11 @@ mod tests {
});
app.update();
let after: Vec<u32> = app.world().resource::<GameStateResource>().0.pile(KlondikePile::Tableau(
Tableau::Tableau1,
))
let after: Vec<u32> = app
.world()
.resource::<GameStateResource>()
.0
.pile(KlondikePile::Tableau(Tableau::Tableau1))
.iter()
.map(|c| c.id)
.collect();
@@ -1415,17 +1416,25 @@ mod tests {
fn settings_changed_updates_take_from_foundation_flag() {
let mut app = test_app(1);
assert!(
app.world().resource::<GameStateResource>().0.take_from_foundation,
app.world()
.resource::<GameStateResource>()
.0
.take_from_foundation,
"fresh game should inherit default take_from_foundation=true",
);
let mut settings = solitaire_data::Settings::default();
settings.take_from_foundation = false;
app.world_mut()
.write_message(crate::settings_plugin::SettingsChangedEvent(settings.clone()));
.write_message(crate::settings_plugin::SettingsChangedEvent(
settings.clone(),
));
app.update();
assert!(
!app.world().resource::<GameStateResource>().0.take_from_foundation,
!app.world()
.resource::<GameStateResource>()
.0
.take_from_foundation,
"settings event must forward take_from_foundation=false into live game state",
);
@@ -1434,7 +1443,10 @@ mod tests {
.write_message(crate::settings_plugin::SettingsChangedEvent(settings));
app.update();
assert!(
app.world().resource::<GameStateResource>().0.take_from_foundation,
app.world()
.resource::<GameStateResource>()
.0
.take_from_foundation,
"settings event must forward take_from_foundation=true into live game state",
);
}
@@ -1557,7 +1569,7 @@ mod tests {
);
}
/// auto_save_game_state writes to disk once the accumulator crosses 30 s.
/// auto_save_game_state writes to disk once the accumulator crosses 30 s.
///
/// The timer is pre-seeded just past the threshold and the test
/// re-arms it before each `app.update()` in a small bounded loop:
@@ -1634,20 +1646,23 @@ mod tests {
// Build a tableau with two face-up cards.
{
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
gs.0.set_test_tableau_cards(Tableau::Tableau1, vec![
Card {
id: 910,
suit: Suit::Clubs,
rank: Rank::King,
face_up: true,
},
Card {
id: 911,
suit: Suit::Hearts,
rank: Rank::Queen,
face_up: true,
},
]);
gs.0.set_test_tableau_cards(
Tableau::Tableau1,
vec![
Card {
id: 910,
suit: Suit::Clubs,
rank: Rank::King,
face_up: true,
},
Card {
id: 911,
suit: Suit::Hearts,
rank: Rank::Queen,
face_up: true,
},
],
);
gs.0.set_test_tableau_cards(
Tableau::Tableau2,
vec![Card {
@@ -1782,7 +1797,7 @@ mod tests {
);
}
#[test]
#[test]
fn has_legal_moves_detects_non_top_face_up_card_as_source() {
// Regression: the bug only checked t.cards.last() (top face-up card).
// If the only legal move involves a face-up card that is NOT the top
@@ -1936,16 +1951,16 @@ mod tests {
);
}
/// Verify that the game-over overlay contains the expected header text and
/// Verify that the game-over overlay contains the expected header text and
/// action-hint strings so players understand why the overlay appeared and
/// what keys to press.
// -----------------------------------------------------------------------
// -----------------------------------------------------------------------
// Task #56 — Escape dismisses GameOverScreen and starts new game
// -----------------------------------------------------------------------
/// Pressing Escape while `GameOverScreen` is visible must fire
/// `NewGameRequestEvent` — identical behaviour to pressing N.
// -----------------------------------------------------------------------
// -----------------------------------------------------------------------
// Task #48 — Undo with empty stack fires InfoToastEvent
// -----------------------------------------------------------------------
@@ -1988,7 +2003,7 @@ mod tests {
/// When a King lands on a foundation that already holds Ace through
/// Queen, exactly one `FoundationCompletedEvent` must fire and carry
/// the matching slot + suit.
/// Moving a card to a tableau pile must never produce a
/// Moving a card to a tableau pile must never produce a
/// `FoundationCompletedEvent`, even if the source tableau happened
/// to have been a King.
#[test]
@@ -2051,7 +2066,7 @@ mod tests {
/// At 12 cards on a foundation (AceJack on the pile, Queen in
/// flight), the event must NOT fire — the flourish is only for the
/// final 13th completion.
/// A successful undo must NOT fire an `InfoToastEvent`.
/// A successful undo must NOT fire an `InfoToastEvent`.
#[test]
fn undo_after_draw_does_not_fire_info_toast() {
let mut app = test_app(42);
@@ -2086,7 +2101,7 @@ mod tests {
/// Drive a fresh game through a draw + a tableau→foundation move,
/// then assert the recording resource captured both, in order, with
/// the correct shape.
/// Invalid moves must not appear in the recording — the recording is
/// Invalid moves must not appear in the recording — the recording is
/// "what successfully happened", not "what was requested".
#[test]
fn replay_does_not_record_rejected_moves() {
@@ -2359,7 +2374,10 @@ mod tests {
Tableau::Tableau7,
] {
assert_eq!(
app.world().resource::<GameStateResource>().0.pile(KlondikePile::Tableau(tableau)),
app.world()
.resource::<GameStateResource>()
.0
.pile(KlondikePile::Tableau(tableau)),
expected.pile(KlondikePile::Tableau(tableau)),
"tableau column {tableau:?} must match the unfiltered seed",
);
+4 -1
View File
@@ -2404,7 +2404,10 @@ fn update_selection_hud(
/// When the slot has a claimed suit (any card has landed) the announcement is
/// "▶ {Suit} Foundation"; while the slot is empty it falls back to a
/// "▶ Foundation N" placeholder labelled by the 1-based slot index.
fn foundation_selection_label(slot: Foundation, game: &solitaire_core::game_state::GameState) -> String {
fn foundation_selection_label(
slot: Foundation,
game: &solitaire_core::game_state::GameState,
) -> String {
let claimed = game
.pile(KlondikePile::Foundation(slot))
.first()
+93 -31
View File
@@ -24,9 +24,9 @@ use bevy::input::touch::{TouchInput, TouchPhase, Touches};
use bevy::math::{Vec2, Vec3};
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use klondike::{Foundation, KlondikePile, Tableau};
#[cfg(not(target_os = "android"))]
use bevy::window::{MonitorSelection, WindowMode};
use klondike::{Foundation, KlondikePile, Tableau};
use solitaire_core::card::{Card, Suit};
use solitaire_core::game_state::GameState;
@@ -789,8 +789,9 @@ fn end_drag(
continue;
};
let target_pos = card_position(&game.0, &layout.0, &origin, stack_index);
if let Some((entity, _, transform)) =
card_entities.iter().find(|(_, ce, _)| ce.card_id == card_id)
if let Some((entity, _, transform)) = card_entities
.iter()
.find(|(_, ce, _)| ce.card_id == card_id)
{
let drag_pos = transform.translation.truncate();
let drag_z = transform.translation.z;
@@ -1027,8 +1028,9 @@ fn touch_end_drag(
continue;
};
let target_pos = card_position(&game.0, &layout.0, &origin, stack_index);
if let Some((entity, _, transform)) =
card_entities.iter().find(|(_, ce, _)| ce.card_id == card_id)
if let Some((entity, _, transform)) = card_entities
.iter()
.find(|(_, ce, _)| ce.card_id == card_id)
{
let drag_pos = transform.translation.truncate();
let drag_z = transform.translation.z;
@@ -1060,6 +1062,13 @@ fn touch_end_drag(
// Helpers
// ---------------------------------------------------------------------------
/// Converts the mouse cursor position to world-space 2-D coordinates.
///
/// **Invariant:** assumes a single un-zoomed 2-D camera whose viewport exactly
/// covers the primary window (centre at world origin, 1 logical pixel = 1 world
/// unit). Hit-testing in `find_draggable_at` / `find_drop_target` relies on
/// this 1:1 mapping. Do not add camera zoom or offset this without auditing
/// every call site of `cursor_world` and `touch_to_world`.
fn cursor_world(
windows: &Query<&Window, With<PrimaryWindow>>,
cameras: &Query<(&Camera, &GlobalTransform)>,
@@ -1073,6 +1082,9 @@ fn cursor_world(
/// Converts a touch screen position (logical pixels, top-left origin) to
/// world-space 2-D coordinates using the primary camera.
///
/// Shares the same 1:1 viewport invariant as [`cursor_world`] — see that
/// function's doc for the constraints.
///
/// Returns `None` if no camera is present or the projection fails.
fn touch_to_world(cameras: &Query<(&Camera, &GlobalTransform)>, screen_pos: Vec2) -> Option<Vec2> {
let (camera, camera_transform) = cameras.single().ok()?;
@@ -1097,7 +1109,12 @@ fn point_in_rect(point: Vec2, center: Vec2, size: Vec2) -> bool {
/// face-up cards by `layout.tableau_fan_frac`. Mirrors `card_plugin::card_positions`
/// exactly; any drift creates an offset between the visible card face and
/// where clicks land.
fn card_position(game: &GameState, layout: &Layout, pile: &KlondikePile, stack_index: usize) -> Vec2 {
fn card_position(
game: &GameState,
layout: &Layout,
pile: &KlondikePile,
stack_index: usize,
) -> Vec2 {
let base = layout.pile_positions[pile];
if matches!(pile, KlondikePile::Tableau(_)) {
let mut y_offset = 0.0_f32;
@@ -1436,6 +1453,7 @@ fn handle_double_tap(
mut touch_selection: Option<ResMut<TouchSelectionState>>,
mut moves: MessageWriter<MoveRequestEvent>,
mut rejected: MessageWriter<MoveRejectedEvent>,
mut toast: MessageWriter<InfoToastEvent>,
mut commands: Commands,
mut card_sprites: Query<(Entity, &CardEntity, &mut Sprite)>,
) {
@@ -1509,8 +1527,9 @@ fn handle_double_tap(
sel.clear();
return;
}
// First tap: select the source.
// First tap: select the source, then nudge the player.
sel.set(*tapped_pile, drag.cards.clone());
toast.write(InfoToastEvent("Tap a pile to move".into()));
}
return;
}
@@ -1540,8 +1559,12 @@ fn handle_double_tap(
if drag.cards.len() > 1 {
let stack_index = pile_cards.len() - drag.cards.len();
if let Some(bottom_card) = pile_cards.get(stack_index)
&& let Some((dest, count)) =
best_tableau_destination_for_stack(bottom_card, tapped_pile, &game.0, drag.cards.len())
&& let Some((dest, count)) = best_tableau_destination_for_stack(
bottom_card,
tapped_pile,
&game.0,
drag.cards.len(),
)
{
for (entity, ce, mut sprite) in card_sprites.iter_mut() {
if drag.cards.contains(&ce.card_id) {
@@ -1573,9 +1596,7 @@ fn handle_double_tap(
// ---------------------------------------------------------------------------
/// Build the complete list of legal moves available in `game`, ordered so that
/// foundation moves come first, then tableau-to-tableau moves, with "draw from
/// stock" appended last when the stock is non-empty and nothing else is
/// available.
/// upstream `klondike` priorities are preserved.
///
/// Each entry is `(from, to, count)` — the same triple used by
/// [`MoveRequestEvent`]. The list may be empty when no move exists at all
@@ -1584,6 +1605,23 @@ fn handle_double_tap(
/// This is the backing data for the cycling hint system: the H key steps
/// through `hints[HintCycleIndex % hints.len()]` on each press.
pub fn all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile, usize)> {
if game.has_test_pile_overrides() {
return legacy_all_hints(game);
}
game.possible_instructions()
.into_iter()
.filter(|(_, _, count)| *count == 1)
.collect()
}
/// Legacy hint enumeration used only when test pile overrides are active.
///
/// `possible_instructions()` reflects the internal upstream `Session` state.
/// In test fixtures that inject synthetic piles via `set_test_*`, these
/// synthetic piles can diverge from the session state; this fallback preserves
/// deterministic test semantics in those fixtures.
fn legacy_all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile, usize)> {
let sources: Vec<KlondikePile> = {
let mut s = vec![KlondikePile::Stock];
for tableau in tableaus() {
@@ -1818,7 +1856,8 @@ mod tests {
// face-up card, but the iterator should skip face-down cards and
// the cursor sits above the face-up card's AABB, so the result
// is None.
let face_down_pos = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 0);
let face_down_pos =
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 0);
let result = find_draggable_at(face_down_pos, &game, &layout);
assert!(result.is_none(), "face-down cards should not be draggable");
}
@@ -1836,7 +1875,8 @@ mod tests {
// Tableau 6 starts with 6 face-down + 1 face-up. The face-up card
// sits at base.y - 6 * TABLEAU_FACEDOWN_FAN_FRAC * card_h, NOT at
// base.y - 6 * TABLEAU_FAN_FRAC * card_h. Click the centre.
let face_up_pos = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 6);
let face_up_pos =
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 6);
let result = find_draggable_at(face_up_pos, &game, &layout)
.expect("clicking the face-up card's visible centre must initiate a drag");
assert_eq!(result.0, KlondikePile::Tableau(Tableau::Tableau7));
@@ -1878,7 +1918,8 @@ mod tests {
// (Jack fans 0.5h below base; its box spans [base-h, base]). To hit the
// Queen we click in her visible strip: the 0.25h band above the Jack's top
// edge (base.y to base.y+0.25h). Midpoint = queen_center + 0.375*card_h.
let queen_center = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau1), 1);
let queen_center =
card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau1), 1);
let pos = queen_center + Vec2::new(0.0, layout.card_size.y * 0.375);
let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau1));
@@ -1923,7 +1964,12 @@ mod tests {
let mut game = game;
game.set_test_tableau_cards(Tableau::Tableau1, Vec::new());
let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
let target = find_drop_target(pos, &game, &layout, &KlondikePile::Tableau(Tableau::Tableau7));
let target = find_drop_target(
pos,
&game,
&layout,
&KlondikePile::Tableau(Tableau::Tableau7),
);
assert_eq!(target, Some(KlondikePile::Tableau(Tableau::Tableau1)));
}
@@ -1932,7 +1978,12 @@ mod tests {
let game = GameState::new(42, DrawMode::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau4)];
let target = find_drop_target(pos, &game, &layout, &KlondikePile::Tableau(Tableau::Tableau4));
let target = find_drop_target(
pos,
&game,
&layout,
&KlondikePile::Tableau(Tableau::Tableau4),
);
assert_eq!(target, None);
}
@@ -2012,7 +2063,10 @@ mod tests {
fn pile_drop_rect_is_card_sized_for_non_tableau() {
let game = GameState::new(42, DrawMode::DrawOne);
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
for pile in [KlondikePile::Stock, KlondikePile::Foundation(Foundation::Foundation3)] {
for pile in [
KlondikePile::Stock,
KlondikePile::Foundation(Foundation::Foundation3),
] {
let (_, size) = pile_drop_rect(&pile, &layout, &game);
assert_eq!(size, layout.card_size);
}
@@ -2022,7 +2076,7 @@ mod tests {
// Task #27 — best_destination pure-function tests
// -----------------------------------------------------------------------
#[test]
#[test]
fn best_destination_returns_none_when_no_legal_move() {
use solitaire_core::card::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawMode::DrawOne);
@@ -2044,7 +2098,7 @@ mod tests {
// best_tableau_destination_for_stack pure-function tests
// -----------------------------------------------------------------------
#[test]
#[test]
fn best_tableau_destination_for_stack_skips_source_pile() {
use solitaire_core::card::{Card, Rank, Suit};
let mut game = GameState::new(1, DrawMode::DrawOne);
@@ -2070,8 +2124,12 @@ mod tests {
rank: Rank::King,
face_up: true,
};
let result =
best_tableau_destination_for_stack(&bottom_card, &KlondikePile::Tableau(Tableau::Tableau1), &game, 1);
let result = best_tableau_destination_for_stack(
&bottom_card,
&KlondikePile::Tableau(Tableau::Tableau1),
&game,
1,
);
// Result must be some other empty tableau column, never the source.
if let Some((dest, _)) = result {
assert_ne!(dest, KlondikePile::Tableau(Tableau::Tableau1));
@@ -2103,8 +2161,12 @@ mod tests {
rank: Rank::Two,
face_up: true,
};
let result =
best_tableau_destination_for_stack(&bottom_card, &KlondikePile::Tableau(Tableau::Tableau1), &game, 1);
let result = best_tableau_destination_for_stack(
&bottom_card,
&KlondikePile::Tableau(Tableau::Tableau1),
&game,
1,
);
assert!(
result.is_none(),
"Two of Clubs has no legal tableau destination on empty piles"
@@ -2140,7 +2202,7 @@ mod tests {
assert_eq!(count, 1);
}
// -----------------------------------------------------------------------
// -----------------------------------------------------------------------
// G key fires ForfeitRequestEvent (modal-based forfeit flow)
// -----------------------------------------------------------------------
@@ -2176,11 +2238,11 @@ mod tests {
clear_test_piles(&mut game);
// Put one card back into the stock so "draw" is a valid suggestion.
game.set_test_stock_cards(vec![Card {
id: 1,
suit: Suit::Clubs,
rank: Rank::Ace,
face_up: false,
}]);
id: 1,
suit: Suit::Clubs,
rank: Rank::Ace,
face_up: false,
}]);
let hints = all_hints(&game);
assert_eq!(hints.len(), 1, "exactly one hint: draw from stock");
@@ -2192,7 +2254,7 @@ mod tests {
/// `all_hints` must be empty when both stock and waste are empty and no
/// pile-to-pile move exists — the game is truly stuck.
// -----------------------------------------------------------------------
// -----------------------------------------------------------------------
// Drag-rejection return tween — `CardAnimation` replaces the legacy
// `ShakeAnim` on the dragged cards. The audio cue
// (`card_invalid.wav` via `MoveRejectedEvent`) is unchanged; only the
+12 -3
View File
@@ -269,7 +269,10 @@ pub fn compute_layout(
5 => Tableau::Tableau6,
_ => Tableau::Tableau7,
};
pile_positions.insert(KlondikePile::Tableau(tableau), Vec2::new(col_x(i), tableau_y));
pile_positions.insert(
KlondikePile::Tableau(tableau),
Vec2::new(col_x(i), tableau_y),
);
}
// Adaptive tableau fan fraction. On height-limited (desktop) windows the
@@ -339,7 +342,9 @@ mod tests {
Tableau::Tableau7,
] {
assert!(
layout.pile_positions.contains_key(&KlondikePile::Tableau(tableau)),
layout
.pile_positions
.contains_key(&KlondikePile::Tableau(tableau)),
"missing tableau {tableau:?}"
);
}
@@ -758,7 +763,11 @@ mod tests {
let window = Vec2::new(360.0, 800.0);
let without = compute_layout(window, 0.0, 0.0, true);
let with_inset = compute_layout(window, 0.0, 48.0, true);
for pile in [KlondikePile::Stock, KlondikePile::Tableau(Tableau::Tableau1), KlondikePile::Tableau(Tableau::Tableau7)] {
for pile in [
KlondikePile::Stock,
KlondikePile::Tableau(Tableau::Tableau1),
KlondikePile::Tableau(Tableau::Tableau7),
] {
assert!(
(without.pile_positions[&pile].x - with_inset.pile_positions[&pile].x).abs() < 1e-3,
"{pile:?} x-position must not change with safe_area_bottom",
+1 -1
View File
@@ -153,7 +153,6 @@ pub use safe_area::{SafeAreaAnchoredTop, SafeAreaInsets, SafeAreaInsetsPlugin};
pub use selection_plugin::{
KeyboardDragState, SelectionHighlight, SelectionPlugin, SelectionState,
};
pub use touch_selection_plugin::{TouchSelectionPlugin, TouchSelectionState};
pub use settings_plugin::{
PendingWindowGeometry, SFX_STEP, SettingsChangedEvent, SettingsPlugin, SettingsResource,
SettingsScreen, WINDOW_GEOMETRY_DEBOUNCE_SECS,
@@ -179,6 +178,7 @@ pub use theme::{
pub use time_attack_plugin::{
TIME_ATTACK_DURATION_SECS, TimeAttackEndedEvent, TimeAttackPlugin, TimeAttackResource,
};
pub use touch_selection_plugin::{TouchSelectionPlugin, TouchSelectionState};
pub use ui_focus::{Disabled, FocusGroup, Focusable, FocusedButton, UiFocusPlugin};
pub use ui_modal::{
ButtonVariant, ModalActions, ModalBody, ModalButton, ModalCard, ModalHeader, ModalScrim,
+5 -2
View File
@@ -26,8 +26,8 @@
use bevy::prelude::*;
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use solitaire_core::game_state::GameState;
use klondike::KlondikePile;
use solitaire_core::game_state::GameState;
use solitaire_core::solver::{SolverConfig, SolverResult, try_solve_from_state};
use crate::card_plugin::CardEntity;
@@ -101,7 +101,10 @@ struct HintTask {
enum HintTaskOutput {
/// Solver verdict was `Winnable`; here is the first move on the
/// solution path.
SolverMove { from: KlondikePile, to: KlondikePile },
SolverMove {
from: KlondikePile,
to: KlondikePile,
},
/// Solver was `Unwinnable` or `Inconclusive`. The poll system
/// runs the legacy heuristic against the live `GameState` so the
/// H key always produces feedback while any legal move exists.
+43 -11
View File
@@ -329,7 +329,12 @@ pub fn find_top_face_up_card_at(
/// Mirror of `input_plugin::card_position` — kept private to this
/// module so the radial's hit-test geometry tracks renderer geometry
/// without depending on `input_plugin` internals.
fn card_position(game: &GameState, layout: &Layout, pile: &KlondikePile, stack_index: usize) -> Vec2 {
fn card_position(
game: &GameState,
layout: &Layout,
pile: &KlondikePile,
stack_index: usize,
) -> Vec2 {
let base = layout.pile_positions[pile];
if matches!(pile, KlondikePile::Tableau(_)) {
let mut y_offset = 0.0_f32;
@@ -376,16 +381,27 @@ const fn tableaus() -> [Tableau; 7] {
}
/// Builds the `(destination, anchor)` list for a fresh radial open.
fn build_radial_destinations(centre: Vec2, dests: Vec<KlondikePile>) -> Vec<(KlondikePile, Vec2)> {
///
/// `half_extents` is the window half-size in world space — icons are clamped
/// so that their edges stay within the viewport, preventing them from appearing
/// off-screen on small or narrow devices.
fn build_radial_destinations(
centre: Vec2,
dests: Vec<KlondikePile>,
half_extents: Vec2,
) -> Vec<(KlondikePile, Vec2)> {
let count = dests.len();
let margin = RADIAL_ICON_SIZE_PX / 2.0;
dests
.into_iter()
.enumerate()
.map(|(i, d)| {
(
d,
radial_anchor_for_index(centre, count, i, RADIAL_RADIUS_PX),
)
let raw = radial_anchor_for_index(centre, count, i, RADIAL_RADIUS_PX);
let clamped = Vec2::new(
raw.x.clamp(-half_extents.x + margin, half_extents.x - margin),
raw.y.clamp(-half_extents.y + margin, half_extents.y - margin),
);
(d, clamped)
})
.collect()
}
@@ -472,7 +488,12 @@ fn radial_open_on_right_click(
});
return;
}
let legal_destinations = build_radial_destinations(world, dests);
let half_extents = windows
.single()
.ok()
.map(|w| Vec2::new(w.width() / 2.0, w.height() / 2.0))
.unwrap_or(Vec2::splat(f32::MAX));
let legal_destinations = build_radial_destinations(world, dests, half_extents);
*state = RightClickRadialState::Active {
source_pile,
@@ -498,6 +519,7 @@ fn radial_open_on_long_press(
drag: Res<DragState>,
paused: Option<Res<PausedResource>>,
touches: Option<Res<Touches>>,
windows: Query<&Window, With<PrimaryWindow>>,
cameras: Query<(&Camera, &GlobalTransform)>,
layout: Option<Res<LayoutResource>>,
game: Option<Res<GameStateResource>>,
@@ -540,7 +562,12 @@ fn radial_open_on_long_press(
if dests.is_empty() {
return;
}
let legal_destinations = build_radial_destinations(world, dests);
let half_extents = windows
.single()
.ok()
.map(|w| Vec2::new(w.width() / 2.0, w.height() / 2.0))
.unwrap_or(Vec2::splat(f32::MAX));
let legal_destinations = build_radial_destinations(world, dests, half_extents);
*state = RightClickRadialState::Active {
source_pile,
count: 1,
@@ -958,7 +985,8 @@ mod tests {
rank: Rank::Ace,
face_up: true,
};
let dests = legal_destinations_for_card(&card, &KlondikePile::Tableau(Tableau::Tableau1), &g);
let dests =
legal_destinations_for_card(&card, &KlondikePile::Tableau(Tableau::Tableau1), &g);
// Ace can be placed on every empty foundation. We only need
// the count to be ≥ 1 and the source pile to be excluded.
assert!(
@@ -977,7 +1005,11 @@ mod tests {
rank: Rank::Ace,
face_up: true,
};
let dests = legal_destinations_for_card(&card, &KlondikePile::Foundation(Foundation::Foundation1), &g);
let dests = legal_destinations_for_card(
&card,
&KlondikePile::Foundation(Foundation::Foundation1),
&g,
);
assert!(!dests.contains(&KlondikePile::Foundation(Foundation::Foundation1)));
}
@@ -988,7 +1020,7 @@ mod tests {
/// Pressing right-click on a face-up card with at least one legal
/// destination must transition the state to `Active` carrying the
/// expected source / count / legal-destination set.
/// Releasing the right button while the cursor is over a destination
/// Releasing the right button while the cursor is over a destination
/// icon must fire a `MoveRequestEvent` and return the state to Idle.
#[test]
fn right_click_release_over_destination_fires_move_request() {
@@ -97,7 +97,11 @@ pub(crate) fn format_move_body(m: &ReplayMove) -> String {
match m {
ReplayMove::StockClick => "stock cycle".to_string(),
ReplayMove::Move { from, to, .. } => {
format!("{} \u{2192} {}", format_saved_pile(from), format_saved_pile(to))
format!(
"{} \u{2192} {}",
format_saved_pile(from),
format_saved_pile(to)
)
}
}
}
+2 -4
View File
@@ -25,15 +25,14 @@
mod format;
mod input;
mod update;
#[cfg(test)]
mod tests;
mod update;
pub(crate) use self::format::*;
pub(crate) use self::input::*;
pub(crate) use self::update::*;
use bevy::prelude::*;
use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent, UndoRequestEvent};
use crate::font_plugin::FontResource;
use crate::platform::SHOW_KEYBOARD_ACCELERATORS;
@@ -44,6 +43,7 @@ use crate::ui_theme::{
STATE_SUCCESS, STATE_SUCCESS_HC, TEXT_PRIMARY, TEXT_PRIMARY_HC, TEXT_SECONDARY, TYPE_BODY,
TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_4, Z_DROP_OVERLAY,
};
use bevy::prelude::*;
// ---------------------------------------------------------------------------
// Z-index — see `ui_theme::Z_MODAL_SCRIM` (200) for the next layer above.
@@ -316,7 +316,6 @@ pub struct ReplayOverlayScrubNotch;
#[derive(Component, Debug)]
pub struct ReplayOverlayScrubNotchLabel;
/// Marker on the keybind-hint footer row at the bottom edge of the
/// banner. Carries two `Text` children: a vim-style mode indicator
/// (`▌ NORMAL │ replay`) on the left and the keybind hint
@@ -1270,4 +1269,3 @@ fn win_move_marker_pct(state: &ReplayPlaybackState) -> Option<f32> {
let frac = (idx as f32 / total as f32).clamp(0.0, 1.0);
Some(frac * 100.0)
}
+17 -6
View File
@@ -854,8 +854,7 @@ fn scrub_notch_labels_carry_helper_strings() {
let mut texts = scrub_notch_label_texts(&mut app);
texts.sort();
let mut expected: Vec<String> =
scrub_notch_labels().iter().map(|s| s.to_string()).collect();
let mut expected: Vec<String> = scrub_notch_labels().iter().map(|s| s.to_string()).collect();
expected.sort();
assert_eq!(
texts, expected,
@@ -1106,10 +1105,22 @@ fn move_log_active_row_text(app: &mut App) -> String {
#[test]
fn format_pile_uses_one_indexed_lowercase_names() {
assert_eq!(format_pile(&KlondikePile::Stock), "waste");
assert_eq!(format_pile(&KlondikePile::Foundation(Foundation::Foundation1)), "foundation 1");
assert_eq!(format_pile(&KlondikePile::Foundation(Foundation::Foundation3)), "foundation 3");
assert_eq!(format_pile(&KlondikePile::Tableau(Tableau::Tableau1)), "tableau 1");
assert_eq!(format_pile(&KlondikePile::Tableau(Tableau::Tableau7)), "tableau 7");
assert_eq!(
format_pile(&KlondikePile::Foundation(Foundation::Foundation1)),
"foundation 1"
);
assert_eq!(
format_pile(&KlondikePile::Foundation(Foundation::Foundation3)),
"foundation 3"
);
assert_eq!(
format_pile(&KlondikePile::Tableau(Tableau::Tableau1)),
"tableau 1"
);
assert_eq!(
format_pile(&KlondikePile::Tableau(Tableau::Tableau7)),
"tableau 7"
);
}
/// Move-body formatter renders `StockClick` as a label and
@@ -1,10 +1,10 @@
use bevy::prelude::*;
use super::*;
use super::format::{
format_active_move_row, format_foundations_row, format_kth_next_row,
format_kth_recent_row, format_move_log_header, format_progress, format_stock_waste_row,
format_active_move_row, format_foundations_row, format_kth_next_row, format_kth_recent_row,
format_move_log_header, format_progress, format_stock_waste_row,
};
use super::*;
use crate::layout::LayoutResource;
use crate::replay_playback::ReplayPlaybackState;
use crate::resources::GameStateResource;
+9 -9
View File
@@ -268,11 +268,12 @@ pub fn step_replay_playback(
}
match &replay.moves[*cursor] {
ReplayMove::Move { from, to, count } => {
let (Ok(from), Ok(to)) = (
KlondikePile::try_from(*from),
KlondikePile::try_from(*to),
) else {
warn!("skipping replay move with invalid pile encoding at cursor {}", *cursor);
let (Ok(from), Ok(to)) = (KlondikePile::try_from(*from), KlondikePile::try_from(*to))
else {
warn!(
"skipping replay move with invalid pile encoding at cursor {}",
*cursor
);
*cursor += 1;
return false;
};
@@ -379,10 +380,9 @@ fn tick_replay_playback(
while *secs_to_next <= 0.0 && *cursor < replay.moves.len() {
match &replay.moves[*cursor] {
ReplayMove::Move { from, to, count } => {
if let (Ok(from), Ok(to)) = (
KlondikePile::try_from(*from),
KlondikePile::try_from(*to),
) {
if let (Ok(from), Ok(to)) =
(KlondikePile::try_from(*from), KlondikePile::try_from(*to))
{
moves_writer.write(MoveRequestEvent {
from,
to,
+1 -1
View File
@@ -6,8 +6,8 @@ use std::sync::Arc;
use bevy::math::Vec2;
use bevy::prelude::Resource;
use chrono::{DateTime, Utc};
use solitaire_core::game_state::GameState;
use klondike::KlondikePile;
use solitaire_core::game_state::GameState;
/// Wraps the currently active `GameState`. Single source of truth for the in-progress game.
#[derive(Resource, Debug, Clone)]
+10 -10
View File
@@ -253,24 +253,24 @@ mod android {
}
}
/// Resets the inset poller and clears cached insets on
/// `AppLifecycle::WillResume` so that `refresh_insets` re-queries JNI in the
/// frames immediately after the app returns to the foreground.
/// Resets the inset poller on `AppLifecycle::WillResume` so that
/// `refresh_insets` re-queries JNI in the frames immediately after the app
/// returns to the foreground.
///
/// Clearing `SafeAreaInsets` to the default (all-zero) fires
/// `on_safe_area_changed` in `table_plugin`, which emits a synthetic
/// `WindowResized`. `on_window_resized` then recomputes the layout;
/// once `refresh_insets` resolves the real values a second synthetic
/// `WindowResized` fires and the layout converges to the correct position.
/// The cached `SafeAreaInsets` are intentionally **not** zeroed here.
/// Zeroing them would cause two layout recomputes on every resume:
/// once with zero insets (wrong position) and again when JNI resolves the
/// real values — visible as a flash. By preserving the last-known values
/// the layout remains stable; if JNI returns a different value (e.g. after
/// a rotation) the single update that fires when `SafeAreaInsets` actually
/// changes is enough.
pub(super) fn rearm_on_resumed(
mut lifecycle: MessageReader<AppLifecycle>,
mut poll: ResMut<SafeAreaPollTries>,
mut insets: ResMut<SafeAreaInsets>,
) {
for event in lifecycle.read() {
if matches!(event, AppLifecycle::WillResume) {
poll.0 = 0;
*insets = SafeAreaInsets::default();
}
}
}
+16 -11
View File
@@ -202,7 +202,10 @@ fn cycled_piles() -> Vec<KlondikePile> {
///
/// If `current` is `None` the first available pile is returned.
/// If `available` is empty, `None` is returned.
pub fn cycle_next_pile(available: &[KlondikePile], current: Option<&KlondikePile>) -> Option<KlondikePile> {
pub fn cycle_next_pile(
available: &[KlondikePile],
current: Option<&KlondikePile>,
) -> Option<KlondikePile> {
if available.is_empty() {
return None;
}
@@ -235,7 +238,11 @@ pub fn cycle_next_pile(available: &[KlondikePile], current: Option<&KlondikePile
///
/// Both `current` and `next` must be `Some`; if either is `None` this returns
/// `false`.
fn did_wrap(available: &[KlondikePile], current: Option<&KlondikePile>, next: Option<&KlondikePile>) -> bool {
fn did_wrap(
available: &[KlondikePile],
current: Option<&KlondikePile>,
next: Option<&KlondikePile>,
) -> bool {
let (Some(cur), Some(nxt)) = (current, next) else {
return false;
};
@@ -386,9 +393,7 @@ fn handle_selection_keys(
KlondikePile::Tableau(Tableau::Tableau7),
];
all.into_iter()
.filter(|p| {
pile_cards(&game.0, p).last().is_some_and(|c| c.face_up)
})
.filter(|p| pile_cards(&game.0, p).last().is_some_and(|c| c.face_up))
.collect()
};
@@ -717,10 +722,7 @@ fn update_selection_highlight(
/// Returns the top face-up card on `pile`, or `None` if the pile is
/// empty or its top card is face-down.
fn top_face_up_card(
pile: &KlondikePile,
game: &GameState,
) -> Option<Card> {
fn top_face_up_card(pile: &KlondikePile, game: &GameState) -> Option<Card> {
pile_cards(game, pile).last().filter(|c| c.face_up).cloned()
}
@@ -1162,14 +1164,17 @@ mod tests {
// DragState must mirror the lifted cards and carry the keyboard sentinel.
let drag = app.world().resource::<DragState>();
assert_eq!(drag.cards, vec![100]);
assert_eq!(drag.origin_pile, Some(KlondikePile::Tableau(Tableau::Tableau1)));
assert_eq!(
drag.origin_pile,
Some(KlondikePile::Tableau(Tableau::Tableau1))
);
assert_eq!(drag.active_touch_id, Some(KEYBOARD_DRAG_TOUCH_ID));
}
/// Test 3 — Arrow keys in `Lifted` cycle through *legal* destinations
/// only (foundations and tableaus that pass `can_place_on_*`), and
/// wrap at the end of the list.
/// Test 4 — Enter while `Lifted` with a destination focused fires
/// Test 4 — Enter while `Lifted` with a destination focused fires
/// exactly one `MoveRequestEvent` and resets the state machine to
/// `Idle` with `DragState` cleared.
#[test]
+1 -1
View File
@@ -33,9 +33,9 @@ use crate::events::{
use crate::font_plugin::FontResource;
use crate::progress_plugin::ProgressResource;
use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource};
use crate::theme::{ThemeThumbnailCache, ThemeThumbnailPair};
#[cfg(not(target_arch = "wasm32"))]
use crate::theme::{ImportError, import_theme, refresh_registry};
use crate::theme::{ThemeThumbnailCache, ThemeThumbnailPair};
use crate::ui_focus::{FocusGroup, FocusRow, Focusable, FocusedButton};
use crate::ui_modal::{
ButtonVariant, ModalButton, ModalScrim, spawn_modal, spawn_modal_actions, spawn_modal_button,
+1 -1
View File
@@ -22,9 +22,9 @@
use std::path::Path;
use bevy::log::warn;
use bevy::prelude::{App, Plugin, Resource};
#[cfg(not(target_arch = "wasm32"))]
use bevy::prelude::Startup;
use bevy::prelude::{App, Plugin, Resource};
use serde::Deserialize;
use super::ThemeMeta;
+21 -15
View File
@@ -77,8 +77,9 @@ impl TouchSelectionState {
/// Marker component placed on the highlight sprite child of a selected source card.
///
/// Despawned and respawned each frame by [`update_touch_selection_highlight`] so
/// stale highlights never linger after a game-state change.
/// Despawned and respawned by [`update_touch_selection_highlight`] whenever
/// [`TouchSelectionState`] changes. The system is gated on `is_changed()` so it
/// is a no-op every frame that the selection is stable.
#[derive(Component)]
pub struct TouchSelectionHighlight;
@@ -91,16 +92,15 @@ pub struct TouchSelectionPlugin;
impl Plugin for TouchSelectionPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<TouchSelectionState>()
.add_systems(
Update,
(
clear_touch_selection_on_state_change,
update_touch_selection_highlight,
)
.chain()
.after(GameMutation),
);
app.init_resource::<TouchSelectionState>().add_systems(
Update,
(
clear_touch_selection_on_state_change,
update_touch_selection_highlight,
)
.chain()
.after(GameMutation),
);
}
}
@@ -121,9 +121,9 @@ pub(crate) fn clear_touch_selection_on_state_change(
/// Maintains the `TouchSelectionHighlight` outline sprite on the selected source card.
///
/// All existing `TouchSelectionHighlight` entities are despawned each frame and
/// a new one is spawned on the top card of the selected pile (if any). This
/// matches the pattern used by `selection_plugin::update_selection_highlight`.
/// Rebuilds the highlight set only when [`TouchSelectionState`] or the layout
/// actually changes — not every frame. Existing highlights are despawned first,
/// then a fresh highlight is spawned on every card in the selected stack.
pub(crate) fn update_touch_selection_highlight(
mut commands: Commands,
selection: Res<TouchSelectionState>,
@@ -131,6 +131,12 @@ pub(crate) fn update_touch_selection_highlight(
highlights: Query<Entity, With<TouchSelectionHighlight>>,
layout: Option<Res<LayoutResource>>,
) {
// Skip when neither the selection nor the layout changed this frame.
let layout_changed = layout.as_ref().map(|l| l.is_changed()).unwrap_or(false);
if !selection.is_changed() && !layout_changed {
return;
}
// Despawn stale highlights first.
for entity in &highlights {
commands.entity(entity).despawn();
+3 -8
View File
@@ -34,11 +34,8 @@ RUN for crate in solitaire_core solitaire_sync solitaire_data solitaire_engine \
echo "fn main() {}" > solitaire_app/src/main.rs && \
echo "fn main() {}" > solitaire_assetgen/src/main.rs
# The Quaternions registry requires authentication. CI passes CI_TOKEN as a
# build secret so it never appears in image layers or docker history.
RUN --mount=type=secret,id=cargo_token,required=true \
CARGO_REGISTRIES_QUATERNIONS_TOKEN="Bearer $(cat /run/secrets/cargo_token)" \
cargo fetch --locked
# Registry config comes from .cargo/config.toml copied above.
RUN cargo fetch --locked
# Now copy real source and build in release mode.
COPY solitaire_core/src ./solitaire_core/src
@@ -51,9 +48,7 @@ COPY solitaire_server/migrations ./solitaire_server/migrations
COPY .sqlx ./.sqlx
ENV SQLX_OFFLINE=true
RUN --mount=type=secret,id=cargo_token,required=true \
CARGO_REGISTRIES_QUATERNIONS_TOKEN="Bearer $(cat /run/secrets/cargo_token)" \
cargo build --release --locked -p solitaire_server --bin solitaire_server
RUN cargo build --release --locked -p solitaire_server --bin solitaire_server
# --- Runtime stage ---
FROM debian:bookworm-slim
+3 -3
View File
@@ -249,11 +249,11 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
const CSP: &str = concat!(
"default-src 'self'; ",
"script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; ",
"script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://analytics.aleshym.co; ",
"style-src 'self' 'unsafe-inline'; ",
"font-src 'self'; ",
"img-src 'self' data:; ",
"connect-src 'self'; ",
"img-src 'self' data: https://analytics.aleshym.co; ",
"connect-src 'self' https://analytics.aleshym.co; ",
"object-src 'none'; ",
"frame-ancestors 'none'",
);
+5 -8
View File
@@ -196,14 +196,11 @@ async fn update_leaderboard_if_opted_in(
user_id: &str,
payload: &SyncPayload,
) -> Result<(), AppError> {
let opted_in = sqlx::query!(
"SELECT leaderboard_opt_in FROM users WHERE id = ?",
user_id
)
.fetch_optional(pool)
.await?
.map(|r| r.leaderboard_opt_in)
.unwrap_or(0);
let opted_in = sqlx::query!("SELECT leaderboard_opt_in FROM users WHERE id = ?", user_id)
.fetch_optional(pool)
.await?
.map(|r| r.leaderboard_opt_in)
.unwrap_or(0);
if opted_in != 1 {
return Ok(());
+256 -18
View File
@@ -176,9 +176,12 @@ async function bootstrap() {
if (saved) {
showResumeDialog(saved);
} else {
const params = new URLSearchParams(window.location.search);
const urlSeed = params.has("seed") ? Number(params.get("seed")) : randomSeed();
drawThree = params.has("draw3");
const params = new URLSearchParams(window.location.search);
const rawSeed = Number(params.get("seed"));
const urlSeed = params.has("seed") && Number.isFinite(rawSeed) && rawSeed > 0
? Math.floor(rawSeed)
: randomSeed();
drawThree = params.has("draw3");
chkDraw3.checked = drawThree;
startGame(urlSeed);
}
@@ -393,8 +396,16 @@ function render(s) {
stopTimer();
if (acTimer) { clearInterval(acTimer); acTimer = null; }
if (noMovesBanner) noMovesBanner.classList.add("hidden");
showWin(s);
// Delay slightly so the last card's CSS transition finishes before
// the win overlay covers the board. Card transitions are ~260 ms.
setTimeout(() => showWin(s), 320);
} else {
// If the player undid out of auto-complete, restart the timer —
// stopTimer() was called when auto-complete began, but no code path
// before here restarts it after an undo.
if (!s.is_auto_completable && !timerInterval) {
startTimer();
}
saveState();
const noMoves = !s.has_moves && !s.is_auto_completable;
if (noMovesBanner) noMovesBanner.classList.toggle("hidden", !noMoves);
@@ -429,20 +440,34 @@ function showWin(s) {
submitReplay(s);
}
async function submitReplay(s) {
const token = localStorage.getItem('fs_token');
if (!token) return;
const payload = {
schema_version: 1,
function buildReplayPayload(s) {
if (!game || !s) return null;
let moves;
try {
moves = game.replay_moves();
if (!Array.isArray(moves) || moves.length === 0) return null;
} catch (e) {
console.warn("fs: replay export failed", e);
return null;
}
return {
schema_version: 2,
seed: Math.round(game.seed()),
draw_mode: drawThree ? "DrawThree" : "DrawOne",
mode: "Classic",
time_seconds: elapsedSecs,
time_seconds: Math.max(1, elapsedSecs),
final_score: s.score,
move_count: s.move_count,
recorded_at: new Date().toISOString().slice(0, 10),
moves: [],
moves,
win_move_index: moves.length - 1,
};
}
async function submitReplay(s) {
const token = localStorage.getItem('fs_token');
if (!token || !game) return;
const payload = buildReplayPayload(s);
if (!payload) return;
try {
await fetch('/api/replays', {
method: 'POST',
@@ -467,7 +492,12 @@ function flashIllegal(cardIds) {
for (const id of cardIds) {
const el = cardEls.get(id);
if (!el) continue;
// Store current translate so the shake keyframe can reference it.
// Remove any in-progress shake before restarting. Reading offsetWidth
// forces a synchronous layout flush so the browser sees the removal
// before we re-add the class, restarting the animation from frame 0.
el.classList.remove("illegal");
el.style.removeProperty("--card-tx");
void el.offsetWidth; // flush layout — do not remove
el.style.setProperty("--card-tx", el.style.transform || "translate(0,0)");
el.classList.add("illegal");
el.addEventListener("animationend", () => {
@@ -496,11 +526,34 @@ function attachHandlers() {
syncThemeButton();
if (game) render(game.state());
});
const doDraw = () => { const r = game.draw(); if (r.ok) render(r.snapshot); };
document.addEventListener("keydown", (e) => {
if (e.target.tagName === "INPUT") return;
if (e.key === "z" || e.key === "Z") doUndo();
if (e.key === "n" || e.key === "N") startGame(randomSeed());
const tag = e.target?.tagName;
if (e.target?.isContentEditable || tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
if (e.key === "z" || e.key === "Z" || e.key === "u" || e.key === "U") {
e.preventDefault();
doUndo();
return;
}
if (e.key === "n" || e.key === "N") {
startGame(randomSeed());
return;
}
if (!e.repeat && (e.code === "Space" || e.key === " ")) {
e.preventDefault();
doDraw();
}
});
// Pause the game timer while the tab is hidden so background time doesn't
// inflate the player's recorded game duration.
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
stopTimer();
} else if (snap && !snap.is_won && !snap.is_auto_completable) {
startTimer();
}
});
board.addEventListener("pointerdown", onPointerDown);
@@ -706,7 +759,7 @@ function onPointerCancel() {
// ── Click / dblclick ──────────────────────────────────────────────────────────
function onBoardClick(e) {
if (drag) return;
if (drag || snap?.is_won) return;
const { x: bx, y: by } = boardRelative(e.clientX, e.clientY);
const stock = PILE_ORIGIN.stock;
if (bx >= stock.x && bx <= stock.x + CARD_W && by >= stock.y && by <= stock.y + CARD_H) {
@@ -741,7 +794,7 @@ function smartMove(pileName, fromIndex) {
}
function onBoardDblClick(e) {
if (drag) return;
if (drag || snap?.is_won) return;
const { x: bx, y: by } = boardRelative(e.clientX, e.clientY);
const hit = hitTestCard(bx, by);
if (!hit || !hit.card.face_up) return;
@@ -782,6 +835,191 @@ async function loadAvatar() {
} catch { /* not signed in — avatar stays hidden */ }
}
function debugStateKey(state) {
if (!state) return "missing";
if (Array.isArray(state.stock) || Array.isArray(state.tableaus)) {
const out = [];
const push = cards => {
for (const c of cards || []) out.push(`${c.id}:${c.face_up ? 1 : 0}`);
out.push("|");
};
push(state.stock);
push(state.waste);
for (const pile of state.foundations || []) push(pile);
for (const pile of state.tableaus || []) push(pile);
return out.join("");
}
return JSON.stringify(state);
}
function orderBaselineDebugMoves(legalMoves) {
const foundationSingles = [];
const moveKind = [];
const rest = [];
for (let i = 0; i < legalMoves.length; i++) {
const move = legalMoves[i];
if (
move?.kind === "move" &&
typeof move.to === "string" &&
move.to.startsWith("foundation-") &&
move.count === 1
) {
foundationSingles.push(i);
} else if (move?.kind === "move") {
moveKind.push(i);
} else {
rest.push(i);
}
}
return [...foundationSingles, ...moveKind, ...rest];
}
function runDebugAutoplay(options = {}) {
if (!game) return { ok: false, reason: "game_not_ready", step: 0 };
const maxSteps = Number.isInteger(options.maxSteps) && options.maxSteps > 0 ? options.maxSteps : 220;
const maxVisitsPerState =
Number.isInteger(options.maxVisitsPerState) && options.maxVisitsPerState > 0
? options.maxVisitsPerState
: 2;
const policy = options.policy === "baseline" ? "baseline" : "loop_aware";
const seen = new Map();
function simulatedVisitCount(legalMoveIndex) {
let saved = null;
try {
saved = game.serialize();
} catch {
return null;
}
if (typeof saved !== "string" || saved.length === 0) return null;
const applied = game.debug_apply_legal_move(legalMoveIndex);
if (!applied?.ok) {
try { game = SolitaireGame.from_saved(saved); } catch {}
return null;
}
const nextKey = debugStateKey(applied.snapshot);
try {
game = SolitaireGame.from_saved(saved);
} catch {
return null;
}
return seen.get(nextKey) || 0;
}
for (let step = 0; step < maxSteps; step++) {
const snap = game.debug_snapshot();
if (!snap?.state || !snap?.invariants) {
return { ok: false, reason: "missing_snapshot", step };
}
if (!snap.invariants.state_ok) {
return { ok: false, reason: "invariant_failed", step, snapshot: snap };
}
if (snap.state.is_won) {
return { ok: true, terminal: "won", step, snapshot: snap };
}
const key = debugStateKey(snap.state);
const visits = (seen.get(key) || 0) + 1;
seen.set(key, visits);
if (visits > maxVisitsPerState) {
return { ok: true, terminal: "cycle", step, snapshot: snap };
}
const legalMoves = game.debug_legal_moves();
if (!Array.isArray(legalMoves) || legalMoves.length === 0) {
return { ok: true, terminal: "no_moves", step, snapshot: snap };
}
const ordered = orderBaselineDebugMoves(legalMoves);
let idx = ordered[0];
if (policy === "loop_aware" && ordered.length > 1) {
let bestIdx = ordered[0];
let bestVisitCount = Number.MAX_SAFE_INTEGER;
for (const candidate of ordered) {
const visitCount = simulatedVisitCount(candidate);
if (visitCount === null) continue;
if (visitCount < bestVisitCount) {
bestVisitCount = visitCount;
bestIdx = candidate;
if (visitCount === 0) break;
}
}
idx = bestIdx;
}
const result = game.debug_apply_legal_move(idx);
if (!result?.ok) {
return {
ok: false,
reason: "apply_failed",
step,
idx,
error: result?.error ?? "unknown_error",
};
}
if (result.snapshot) render(result.snapshot);
}
const finalSnap = game.debug_snapshot();
return { ok: !!finalSnap?.invariants?.state_ok, terminal: "step_budget", snapshot: finalSnap };
}
// ── Debug API (engine-first automation surface) ───────────────────────────────
// Playwright and other automation harnesses use this object instead of pixel
// analysis or hardcoded coordinates. Every operation delegates to the Rust
// rules engine exported by `solitaire_wasm`.
window.__FERROUS_DEBUG__ = {
seed() {
return game ? Math.round(game.seed()) : null;
},
state() {
return game ? game.state() : null;
},
legalMoves() {
return game ? game.debug_legal_moves() : [];
},
moveHistory() {
return game ? game.debug_move_history() : [];
},
snapshot() {
return game ? game.debug_snapshot() : null;
},
applyLegalMove(index) {
if (!game) return { ok: false, error: "game_not_ready" };
const result = game.debug_apply_legal_move(index);
if (result?.ok && result.snapshot) render(result.snapshot);
return result;
},
applyMove(move) {
if (!game) return { ok: false, error: "game_not_ready" };
const payload = typeof move === "string" ? move : JSON.stringify(move);
const result = game.debug_apply_move_json(payload);
if (result?.ok && result.snapshot) render(result.snapshot);
return result;
},
failureReport() {
if (!game) return null;
const debug = game.debug_snapshot();
return {
seed: Math.round(game.seed()),
moveHistory: debug?.move_history ?? [],
currentState: debug?.state ?? game.state(),
stateJson: debug?.state_json ?? null,
legalMoves: debug?.legal_moves ?? [],
invariants: debug?.invariants ?? null,
};
},
replayPayload() {
if (!game) return null;
return buildReplayPayload(snap ?? game.state());
},
runAutoplay(options) {
return runDebugAutoplay(options);
},
};
// ── Start ─────────────────────────────────────────────────────────────────────
bootstrap().catch(console.error);
loadAvatar();
Binary file not shown.
@@ -122,6 +122,67 @@ export class SolitaireGame {
const ret = wasm.solitairegame_auto_complete_step(this.__wbg_ptr);
return ret;
}
/**
* Applies the legal move currently at `index` from `debug_legal_moves()`.
* @param {number} index
* @returns {any}
*/
debug_apply_legal_move(index) {
const ret = wasm.solitairegame_debug_apply_legal_move(this.__wbg_ptr, index);
return ret;
}
/**
* Applies one debug move encoded as JSON.
*
* JSON must match [`DebugMove`], for example:
* `{"kind":"move","from":"tableau-0","to":"foundation-1","count":1}` or
* `{"kind":"stock_click"}`.
* @param {string} move_json
* @returns {any}
*/
debug_apply_move_json(move_json) {
const ptr0 = passStringToWasm0(move_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.solitairegame_debug_apply_move_json(this.__wbg_ptr, ptr0, len0);
return ret;
}
/**
* Returns all currently-legal debug moves as a JS array.
*
* Includes [`DebugMove::StockClick`] when stock interaction is legal.
* @returns {any}
*/
debug_legal_moves() {
const ret = wasm.solitairegame_debug_legal_moves(this.__wbg_ptr);
if (ret[2]) {
throw takeFromExternrefTable0(ret[1]);
}
return takeFromExternrefTable0(ret[0]);
}
/**
* Returns deterministic instruction history for the current game.
*
* Together with `seed()` and `draw_mode`, this history is replayable.
* @returns {any}
*/
debug_move_history() {
const ret = wasm.solitairegame_debug_move_history(this.__wbg_ptr);
if (ret[2]) {
throw takeFromExternrefTable0(ret[1]);
}
return takeFromExternrefTable0(ret[0]);
}
/**
* Returns a comprehensive debug snapshot for automated verification.
* @returns {any}
*/
debug_snapshot() {
const ret = wasm.solitairegame_debug_snapshot(this.__wbg_ptr);
if (ret[2]) {
throw takeFromExternrefTable0(ret[1]);
}
return takeFromExternrefTable0(ret[0]);
}
/**
* Draw from stock to waste (or recycle waste stock when stock is empty).
* Returns `{ok, error?, snapshot?}`.
@@ -182,6 +243,21 @@ export class SolitaireGame {
SolitaireGameFinalization.register(this, this.__wbg_ptr, this);
return this;
}
/**
* Returns replay moves encoded in the `solitaire_data::Replay` wire format.
*
* This derives move counts from the deterministic instruction history and
* validates that the resulting move stream replays cleanly from the current
* game's seed/draw mode.
* @returns {any}
*/
replay_moves() {
const ret = wasm.solitairegame_replay_moves(this.__wbg_ptr);
if (ret[2]) {
throw takeFromExternrefTable0(ret[1]);
}
return takeFromExternrefTable0(ret[0]);
}
/**
* The seed used to deal this game.
* @returns {number}
Binary file not shown.
+629 -12
View File
@@ -24,7 +24,9 @@ use serde::{Deserialize, Serialize};
use solitaire_core::card::Suit;
use solitaire_core::error::MoveError;
use solitaire_core::game_state::{DrawMode, GameMode, GameState};
use solitaire_core::klondike_adapter::SavedKlondikePile;
use solitaire_core::klondike_adapter::{
SavedInstruction, SavedKlondikePile, SavedKlondikePileStack, tableau_from_index,
};
use wasm_bindgen::prelude::*;
/// Mirrors the variants of `solitaire_data::ReplayMove` v2 (atomic
@@ -55,7 +57,7 @@ pub struct Replay {
}
/// JS-friendly snapshot of a `GameState` at a particular replay step.
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct StateSnapshot {
pub step_idx: usize,
pub total_steps: usize,
@@ -75,7 +77,7 @@ pub struct StateSnapshot {
/// means the card back is drawn; in that case `suit` and `rank` are
/// still set (so the renderer doesn't need separate "unknown" data),
/// just hidden visually.
#[derive(Debug, Clone, Copy, Serialize)]
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
pub struct CardSnapshot {
pub id: u32,
/// `"clubs" | "diamonds" | "hearts" | "spades"`.
@@ -157,8 +159,9 @@ impl ReplayPlayer {
}
fn snapshot(&self) -> StateSnapshot {
let pile_cards =
|t: KlondikePile| -> Vec<CardSnapshot> { self.game.pile(t).iter().map(CardSnapshot::from).collect() };
let pile_cards = |t: KlondikePile| -> Vec<CardSnapshot> {
self.game.pile(t).iter().map(CardSnapshot::from).collect()
};
let foundations: [Vec<CardSnapshot>; 4] = [
pile_cards(KlondikePile::Foundation(Foundation::Foundation1)),
pile_cards(KlondikePile::Foundation(Foundation::Foundation2)),
@@ -180,8 +183,18 @@ impl ReplayPlayer {
score: self.game.score,
move_count: self.game.move_count,
is_won: self.game.is_won,
stock: self.game.stock_cards().iter().map(CardSnapshot::from).collect(),
waste: self.game.waste_cards().iter().map(CardSnapshot::from).collect(),
stock: self
.game
.stock_cards()
.iter()
.map(CardSnapshot::from)
.collect(),
waste: self
.game
.waste_cards()
.iter()
.map(CardSnapshot::from)
.collect(),
foundations,
tableaus,
}
@@ -252,7 +265,7 @@ impl ReplayPlayer {
// ---------------------------------------------------------------------------
/// Full snapshot of a live `SolitaireGame` for the JS renderer.
#[derive(Debug, Clone, Serialize)]
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct GameSnapshot {
pub score: i32,
pub move_count: u32,
@@ -279,6 +292,174 @@ pub struct ActionResult {
pub snapshot: Option<GameSnapshot>,
}
/// Debug action understood by the automation-oriented debug API.
///
/// This mirrors legal player inputs and is intentionally independent from DOM
/// or pointer coordinates so test runners can drive the engine directly.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum DebugMove {
Move {
from: String,
to: String,
count: usize,
},
StockClick,
}
/// Invariant report returned by the debug API after each step.
///
/// `state_ok` is `true` when no structural violations were detected.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct DebugInvariantReport {
pub state_ok: bool,
pub total_cards_seen: usize,
pub duplicate_card_ids: Vec<u32>,
pub missing_card_ids: Vec<u32>,
pub out_of_range_card_ids: Vec<u32>,
pub stock_has_face_up_cards: bool,
pub waste_has_face_down_cards: bool,
pub foundation_has_face_down_cards: bool,
pub tableau_visibility_violation: bool,
pub soft_lock: bool,
}
/// Full debug snapshot for engine-integration and browser automation tests.
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct DebugSnapshot {
pub seed: u64,
pub draw_mode: DrawMode,
pub mode: GameMode,
pub state: GameSnapshot,
pub legal_moves: Vec<DebugMove>,
pub move_history: Vec<SavedInstruction>,
pub invariants: DebugInvariantReport,
pub state_json: String,
}
fn pile_name(pile: KlondikePile) -> String {
match pile {
KlondikePile::Stock => "stock".to_string(),
KlondikePile::Foundation(f) => format!("foundation-{}", f as u8),
KlondikePile::Tableau(t) => format!("tableau-{}", t as u8),
}
}
fn can_stock_click(game: &GameState) -> bool {
!(game.is_won || game.stock_cards().is_empty() && game.waste_cards().is_empty())
}
fn legal_moves_for_game(game: &GameState) -> Vec<DebugMove> {
let mut moves: Vec<DebugMove> = game
.possible_instructions()
.into_iter()
.map(|(from, to, count)| DebugMove::Move {
from: pile_name(from),
to: pile_name(to),
count,
})
.collect();
if can_stock_click(game) {
moves.push(DebugMove::StockClick);
}
moves
}
fn invariant_report_for_game(game: &GameState, legal_moves: &[DebugMove]) -> DebugInvariantReport {
let stock = game.stock_cards();
let waste = game.waste_cards();
let foundations = [
game.pile(KlondikePile::Foundation(Foundation::Foundation1)),
game.pile(KlondikePile::Foundation(Foundation::Foundation2)),
game.pile(KlondikePile::Foundation(Foundation::Foundation3)),
game.pile(KlondikePile::Foundation(Foundation::Foundation4)),
];
let tableaus = [
game.pile(KlondikePile::Tableau(Tableau::Tableau1)),
game.pile(KlondikePile::Tableau(Tableau::Tableau2)),
game.pile(KlondikePile::Tableau(Tableau::Tableau3)),
game.pile(KlondikePile::Tableau(Tableau::Tableau4)),
game.pile(KlondikePile::Tableau(Tableau::Tableau5)),
game.pile(KlondikePile::Tableau(Tableau::Tableau6)),
game.pile(KlondikePile::Tableau(Tableau::Tableau7)),
];
let mut seen = [false; 52];
let mut duplicate_card_ids = Vec::new();
let mut out_of_range_card_ids = Vec::new();
let mut total_cards_seen = 0_usize;
let mut feed = |cards: &[solitaire_core::card::Card]| {
for card in cards {
total_cards_seen += 1;
if card.id >= 52 {
out_of_range_card_ids.push(card.id);
continue;
}
let idx = card.id as usize;
if seen[idx] {
duplicate_card_ids.push(card.id);
} else {
seen[idx] = true;
}
}
};
feed(&stock);
feed(&waste);
for pile in &foundations {
feed(pile);
}
for pile in &tableaus {
feed(pile);
}
let missing_card_ids = (0_u32..52_u32)
.filter(|id| !seen[*id as usize])
.collect::<Vec<_>>();
let stock_has_face_up_cards = stock.iter().any(|c| c.face_up);
let waste_has_face_down_cards = waste.iter().any(|c| !c.face_up);
let foundation_has_face_down_cards = foundations
.iter()
.any(|pile| pile.iter().any(|c| !c.face_up));
let tableau_visibility_violation = tableaus.iter().any(|pile| {
let mut seen_face_up = false;
for card in pile {
if card.face_up {
seen_face_up = true;
} else if seen_face_up {
return true;
}
}
false
});
let soft_lock = !game.is_won && stock.is_empty() && waste.is_empty() && legal_moves.is_empty();
let state_ok = duplicate_card_ids.is_empty()
&& missing_card_ids.is_empty()
&& out_of_range_card_ids.is_empty()
&& !stock_has_face_up_cards
&& !waste_has_face_down_cards
&& !foundation_has_face_down_cards
&& !tableau_visibility_violation;
DebugInvariantReport {
state_ok,
total_cards_seen,
duplicate_card_ids,
missing_card_ids,
out_of_range_card_ids,
stock_has_face_up_cards,
waste_has_face_down_cards,
foundation_has_face_down_cards,
tableau_visibility_violation,
soft_lock,
}
}
/// Interactive Klondike game backed by the real `solitaire_core` rules engine.
///
/// Construct with `new(seed, draw_three)`, then call `draw()`, `move_cards()`,
@@ -291,8 +472,9 @@ pub struct SolitaireGame {
impl SolitaireGame {
fn snap(&self) -> GameSnapshot {
let cards =
|t: KlondikePile| -> Vec<CardSnapshot> { self.game.pile(t).iter().map(CardSnapshot::from).collect() };
let cards = |t: KlondikePile| -> Vec<CardSnapshot> {
self.game.pile(t).iter().map(CardSnapshot::from).collect()
};
let has_moves = {
let stock_empty = self.game.stock_cards().is_empty();
let waste_empty = self.game.waste_cards().is_empty();
@@ -306,8 +488,18 @@ impl SolitaireGame {
has_moves,
undo_count: self.game.undo_count,
undo_stack_len: self.game.undo_stack_len(),
stock: self.game.stock_cards().iter().map(CardSnapshot::from).collect(),
waste: self.game.waste_cards().iter().map(CardSnapshot::from).collect(),
stock: self
.game
.stock_cards()
.iter()
.map(CardSnapshot::from)
.collect(),
waste: self
.game
.waste_cards()
.iter()
.map(CardSnapshot::from)
.collect(),
foundations: [
cards(KlondikePile::Foundation(Foundation::Foundation1)),
cards(KlondikePile::Foundation(Foundation::Foundation2)),
@@ -366,6 +558,138 @@ impl SolitaireGame {
}
}
fn legal_moves_native(&self) -> Vec<DebugMove> {
legal_moves_for_game(&self.game)
}
fn move_history_native(&self) -> Vec<SavedInstruction> {
self.game.instruction_history()
}
fn replay_moves_native(&self) -> Result<Vec<ReplayMove>, String> {
let mut replay_game =
GameState::new_with_mode(self.game.seed, self.game.draw_mode, self.game.mode);
let mut replay_moves = Vec::new();
for instruction in self.game.instruction_history() {
let replay_move = match instruction {
SavedInstruction::RotateStock => ReplayMove::StockClick,
SavedInstruction::DstFoundation(dst) => ReplayMove::Move {
from: dst.src,
to: SavedKlondikePile::Foundation(dst.foundation),
count: 1,
},
SavedInstruction::DstTableau(dst) => {
let (from, count) = match dst.src {
SavedKlondikePileStack::Stock => (SavedKlondikePile::Stock, 1),
SavedKlondikePileStack::Foundation(foundation) => {
(SavedKlondikePile::Foundation(foundation), 1)
}
SavedKlondikePileStack::Tableau(tableau_stack) => {
let tableau =
tableau_from_index(tableau_stack.tableau.0 as usize).ok_or_else(
|| {
format!(
"invalid tableau index in move history: {}",
tableau_stack.tableau.0
)
},
)?;
let face_up_count = replay_game
.pile(KlondikePile::Tableau(tableau))
.iter()
.rev()
.take_while(|card| card.face_up)
.count();
let skip = tableau_stack.skip_cards.0 as usize;
let count = face_up_count.checked_sub(skip).ok_or_else(|| {
format!(
"invalid tableau skip in move history: face_up={face_up_count}, skip={skip}"
)
})?;
if count == 0 {
return Err(
"invalid tableau move in move history: zero-card move".into()
);
}
(SavedKlondikePile::Tableau(tableau_stack.tableau), count)
}
};
ReplayMove::Move {
from,
to: SavedKlondikePile::Tableau(dst.tableau),
count,
}
}
};
match &replay_move {
ReplayMove::StockClick => replay_game
.draw()
.map_err(|e| format!("failed to apply stock click while exporting replay: {e}"))?,
ReplayMove::Move { from, to, count } => {
let src: KlondikePile = (*from)
.try_into()
.map_err(|e| format!("invalid replay source pile: {e}"))?;
let dst: KlondikePile = (*to)
.try_into()
.map_err(|e| format!("invalid replay destination pile: {e}"))?;
replay_game.move_cards(src, dst, *count).map_err(|e| {
format!(
"failed to apply move while exporting replay ({from:?} -> {to:?}, count={count}): {e}"
)
})?;
}
}
replay_moves.push(replay_move);
}
Ok(replay_moves)
}
fn debug_snapshot_native(&self) -> DebugSnapshot {
let legal_moves = self.legal_moves_native();
let invariants = invariant_report_for_game(&self.game, &legal_moves);
let state_json = serde_json::to_string(&self.game).unwrap_or_default();
DebugSnapshot {
seed: self.game.seed,
draw_mode: self.game.draw_mode,
mode: self.game.mode,
state: self.snap(),
legal_moves,
move_history: self.move_history_native(),
invariants,
state_json,
}
}
fn apply_debug_move_native(&mut self, mv: &DebugMove) -> Result<(), String> {
match mv {
DebugMove::StockClick => self.game.draw().map_err(|e| e.to_string()),
DebugMove::Move { from, to, count } => {
let from_pile = Self::pile_from_str(from)?;
let to_pile = Self::pile_from_str(to)?;
if from_pile == KlondikePile::Stock && to_pile == KlondikePile::Stock {
self.game.draw().map_err(|e| e.to_string())
} else {
self.game
.move_cards(from_pile, to_pile, *count)
.map_err(|e| e.to_string())
}
}
}
}
fn apply_legal_move_native(&mut self, index: usize) -> Result<(), String> {
let legal_moves = self.legal_moves_native();
let mv = legal_moves
.get(index)
.ok_or_else(|| format!("legal move index out of range: {index}"))?
.clone();
self.apply_debug_move_native(&mv)
}
fn ok_js(&self) -> JsValue {
serde_wasm_bindgen::to_value(&ActionResult {
ok: true,
@@ -497,4 +821,297 @@ impl SolitaireGame {
Err(_) => JsValue::NULL,
}
}
/// Returns replay moves encoded in the `solitaire_data::Replay` wire format.
///
/// This derives move counts from the deterministic instruction history and
/// validates that the resulting move stream replays cleanly from the current
/// game's seed/draw mode.
pub fn replay_moves(&self) -> Result<JsValue, JsValue> {
let moves = self
.replay_moves_native()
.map_err(|e| JsValue::from_str(&e.to_string()))?;
serde_wasm_bindgen::to_value(&moves).map_err(|e| JsValue::from_str(&e.to_string()))
}
/// Returns all currently-legal debug moves as a JS array.
///
/// Includes [`DebugMove::StockClick`] when stock interaction is legal.
pub fn debug_legal_moves(&self) -> Result<JsValue, JsValue> {
serde_wasm_bindgen::to_value(&self.legal_moves_native())
.map_err(|e| JsValue::from_str(&e.to_string()))
}
/// Returns deterministic instruction history for the current game.
///
/// Together with `seed()` and `draw_mode`, this history is replayable.
pub fn debug_move_history(&self) -> Result<JsValue, JsValue> {
serde_wasm_bindgen::to_value(&self.move_history_native())
.map_err(|e| JsValue::from_str(&e.to_string()))
}
/// Returns a comprehensive debug snapshot for automated verification.
pub fn debug_snapshot(&self) -> Result<JsValue, JsValue> {
serde_wasm_bindgen::to_value(&self.debug_snapshot_native())
.map_err(|e| JsValue::from_str(&e.to_string()))
}
/// Applies the legal move currently at `index` from `debug_legal_moves()`.
pub fn debug_apply_legal_move(&mut self, index: usize) -> JsValue {
match self.apply_legal_move_native(index) {
Ok(()) => self.ok_js(),
Err(e) => Self::err_js(e),
}
}
/// Applies one debug move encoded as JSON.
///
/// JSON must match [`DebugMove`], for example:
/// `{"kind":"move","from":"tableau-0","to":"foundation-1","count":1}` or
/// `{"kind":"stock_click"}`.
pub fn debug_apply_move_json(&mut self, move_json: &str) -> JsValue {
let parsed = match serde_json::from_str::<DebugMove>(move_json) {
Ok(value) => value,
Err(e) => return Self::err_js(format!("invalid debug move JSON: {e}")),
};
match self.apply_debug_move_native(&parsed) {
Ok(()) => self.ok_js(),
Err(e) => Self::err_js(e),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
use std::fmt::Write;
fn pick_move_index(moves: &[DebugMove]) -> Option<usize> {
if moves.is_empty() {
return None;
}
if let Some((idx, _)) = moves.iter().enumerate().find(|(_, m)| {
matches!(
m,
DebugMove::Move {
to,
count: 1,
..
} if to.starts_with("foundation-")
)
}) {
return Some(idx);
}
if let Some((idx, _)) = moves
.iter()
.enumerate()
.find(|(_, m)| matches!(m, DebugMove::Move { .. }))
{
return Some(idx);
}
Some(0)
}
fn assert_invariants(snapshot: &DebugSnapshot, seed: u64) {
assert!(
snapshot.invariants.state_ok,
"state invariant failure (seed={seed}): {:?}",
snapshot.invariants
);
}
fn board_key(state: &GameSnapshot) -> String {
let mut key = String::new();
let mut push_cards = |cards: &[CardSnapshot]| {
for card in cards {
let _ = write!(
key,
"{}:{}:{},",
card.id,
card.rank,
if card.face_up { 1 } else { 0 }
);
}
key.push('|');
};
push_cards(&state.stock);
push_cards(&state.waste);
for pile in &state.foundations {
push_cards(pile);
}
for pile in &state.tableaus {
push_cards(pile);
}
key
}
fn run_autonomous(seed: u64, draw_mode: DrawMode, max_steps: usize) -> DebugSnapshot {
let mut game = SolitaireGame {
game: GameState::new_with_mode(seed, draw_mode, GameMode::Classic),
};
let mut last_snapshot = game.debug_snapshot_native();
let mut seen_states = HashSet::new();
seen_states.insert(board_key(&last_snapshot.state));
assert_invariants(&last_snapshot, seed);
for step in 0..max_steps {
if last_snapshot.state.is_won || last_snapshot.legal_moves.is_empty() {
return last_snapshot;
}
let idx = pick_move_index(&last_snapshot.legal_moves).unwrap_or_default();
if let Err(e) = game.apply_legal_move_native(idx) {
panic!("failed to apply legal move (seed={seed}, step={step}, idx={idx}): {e}");
}
last_snapshot = game.debug_snapshot_native();
if !seen_states.insert(board_key(&last_snapshot.state)) {
// Deterministic autoplay returned to an earlier state.
// Treat as a terminal non-winning run, not a harness failure.
return last_snapshot;
}
assert_invariants(&last_snapshot, seed);
}
panic!("autonomous run exceeded step budget (seed={seed}, max_steps={max_steps})");
}
#[test]
fn debug_snapshot_exposes_replayable_seed_and_history() {
let seed = 42_u64;
let final_snapshot = run_autonomous(seed, DrawMode::DrawOne, 1500);
assert_eq!(final_snapshot.seed, seed);
assert!(
!final_snapshot.state_json.is_empty(),
"debug snapshot must include serialised current state"
);
let restored = match SolitaireGame::from_saved(&final_snapshot.state_json) {
Ok(game) => game,
Err(err) => panic!("failed to restore debug snapshot state: {err:?}"),
};
let restored_snapshot = restored.debug_snapshot_native();
assert_eq!(restored_snapshot.state, final_snapshot.state);
}
#[test]
fn replay_moves_export_is_json_compatible_and_replayable() {
let seed = 7_u64;
let draw_mode = DrawMode::DrawThree;
let mut game = SolitaireGame {
game: GameState::new_with_mode(seed, draw_mode, GameMode::Classic),
};
for step in 0..64 {
let legal_moves = game.legal_moves_native();
if legal_moves.is_empty() {
break;
}
let idx = pick_move_index(&legal_moves).unwrap_or_default();
if let Err(e) = game.apply_legal_move_native(idx) {
panic!("failed to advance game before replay export (seed={seed}, step={step}, idx={idx}): {e}");
}
}
let exported_moves = match game.replay_moves_native() {
Ok(moves) => moves,
Err(err) => panic!("replay export failed: {err}"),
};
assert!(
!exported_moves.is_empty(),
"progressed game must export a non-empty replay move list"
);
let moves_json = match serde_json::to_value(&exported_moves) {
Ok(value) => value,
Err(err) => panic!("failed to serialise exported replay moves: {err}"),
};
let array = match moves_json.as_array() {
Some(values) => values,
None => panic!("exported replay moves must serialise as a JSON array"),
};
assert!(
array.iter().all(|entry| {
entry.as_str() == Some("StockClick") || entry.get("Move").is_some()
}),
"replay move JSON must match ReplayMove wire shape"
);
let parsed_back: Vec<ReplayMove> = match serde_json::from_value(moves_json) {
Ok(parsed) => parsed,
Err(err) => panic!("failed to parse replay move JSON as ReplayMove list: {err}"),
};
assert_eq!(
parsed_back, exported_moves,
"replay move JSON must round-trip through ReplayMove"
);
let recorded_at = match NaiveDate::from_ymd_opt(2026, 6, 1) {
Some(date) => date,
None => panic!("invalid recorded_at date in test"),
};
let replay = Replay {
schema_version: 2,
seed,
draw_mode,
mode: GameMode::Classic,
time_seconds: 120,
final_score: game.game.score,
recorded_at,
moves: exported_moves,
};
let replay_json = match serde_json::to_string(&replay) {
Ok(json) => json,
Err(err) => panic!("failed to serialise replay JSON: {err}"),
};
let mut player = match ReplayPlayer::from_json(&replay_json) {
Ok(value) => value,
Err(err) => panic!("failed to construct replay player: {err}"),
};
loop {
match player.step_native() {
Ok(Some(_)) => {}
Ok(None) => break,
Err(err) => panic!("replay player desynced while applying exported moves: {err}"),
}
}
let original_state = match serde_json::to_string(&game.game) {
Ok(json) => json,
Err(err) => panic!("failed to serialise original game state: {err}"),
};
let replayed_state = match serde_json::to_string(&player.game) {
Ok(json) => json,
Err(err) => panic!("failed to serialise replayed game state: {err}"),
};
assert_eq!(
replayed_state, original_state,
"replayed state must match the live state the moves were exported from"
);
}
#[test]
fn debug_api_autonomous_seed_batch_smoke() {
for seed in 0_u64..128_u64 {
let draw_mode = if seed % 2 == 0 {
DrawMode::DrawOne
} else {
DrawMode::DrawThree
};
let snapshot = run_autonomous(seed, draw_mode, 2000);
assert_invariants(&snapshot, seed);
}
}
#[test]
#[ignore = "long-running soak for unattended CI pipelines"]
fn debug_api_autonomous_thousands_seed_soak() {
for seed in 10_000_u64..12_000_u64 {
let draw_mode = if seed % 2 == 0 {
DrawMode::DrawOne
} else {
DrawMode::DrawThree
};
let snapshot = run_autonomous(seed, draw_mode, 3000);
assert_invariants(&snapshot, seed);
}
}
}