Compare commits
4 Commits
20e5222148
...
1cdb78caf2
| Author | SHA1 | Date | |
|---|---|---|---|
| 1cdb78caf2 | |||
| baf524ec75 | |||
| 9ff0585454 | |||
| 64f975ed6d |
@@ -6,6 +6,8 @@ on:
|
|||||||
branches: [master]
|
branches: [master]
|
||||||
paths:
|
paths:
|
||||||
- 'solitaire_server/**'
|
- 'solitaire_server/**'
|
||||||
|
- 'solitaire_wasm/**'
|
||||||
|
- 'solitaire_web/**'
|
||||||
- 'solitaire_sync/**'
|
- 'solitaire_sync/**'
|
||||||
- 'solitaire_core/**'
|
- 'solitaire_core/**'
|
||||||
- 'solitaire_engine/**'
|
- 'solitaire_engine/**'
|
||||||
@@ -34,6 +36,48 @@ jobs:
|
|||||||
id: meta
|
id: meta
|
||||||
run: echo "sha=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT"
|
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
|
- name: Log in to Gitea registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
@@ -57,8 +101,6 @@ jobs:
|
|||||||
${{ env.IMAGE }}:latest
|
${{ env.IMAGE }}:latest
|
||||||
cache-from: type=registry,ref=${{ env.IMAGE }}:buildcache
|
cache-from: type=registry,ref=${{ env.IMAGE }}:buildcache
|
||||||
cache-to: type=registry,ref=${{ env.IMAGE }}:buildcache,mode=max
|
cache-to: type=registry,ref=${{ env.IMAGE }}:buildcache,mode=max
|
||||||
secrets: |
|
|
||||||
cargo_token=${{ secrets.CI_TOKEN }}
|
|
||||||
|
|
||||||
- name: Install kustomize
|
- name: Install kustomize
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ agentdb.rvf.lock
|
|||||||
# IDE project files
|
# IDE project files
|
||||||
.idea/
|
.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
|
# Android signing keystores — never commit
|
||||||
*.jks
|
*.jks
|
||||||
*.jks.bak
|
*.jks.bak
|
||||||
|
|||||||
@@ -118,8 +118,28 @@ cargo test -p solitaire_core -p solitaire_sync -p solitaire_data -p solitaire_se
|
|||||||
|
|
||||||
# Lint
|
# Lint
|
||||||
cargo clippy --workspace --all-targets -- -D warnings
|
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
|
## Credits
|
||||||
|
|
||||||
Built on [Bevy](https://bevyengine.org/) and the wider Rust ecosystem
|
Built on [Bevy](https://bevyengine.org/) and the wider Rust ecosystem
|
||||||
|
|||||||
+5
-1
@@ -65,7 +65,11 @@ wasm-bindgen \
|
|||||||
# wasm-opt passes are skipped silently when the tool is not installed.
|
# wasm-opt passes are skipped silently when the tool is not installed.
|
||||||
if command -v wasm-opt &> /dev/null; then
|
if command -v wasm-opt &> /dev/null; then
|
||||||
echo "Running wasm-opt on canvas_bg.wasm..."
|
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" \
|
-o "$OUT_DIR/canvas_bg.wasm" \
|
||||||
"$OUT_DIR/canvas_bg.wasm"
|
"$OUT_DIR/canvas_bg.wasm"
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -101,6 +101,11 @@ Our 767-line `solitaire_core::solver` reimplements the full game rules to run th
|
|||||||
### 5. JSON Serialisation / Persistence
|
### 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.
|
`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.
|
**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.
|
**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
|
## What Does NOT Need to Change
|
||||||
|
|
||||||
- The `solitaire_engine` Bevy layer — it works against `solitaire_core` types; changes are isolated to `solitaire_core`.
|
- The `solitaire_engine` Bevy layer — it works against `solitaire_core` types; changes are isolated to `solitaire_core`.
|
||||||
|
|||||||
@@ -25,7 +25,10 @@ use bevy::window::{MonitorSelection, PresentMode, WindowPosition};
|
|||||||
use bevy::winit::WinitWindows;
|
use bevy::winit::WinitWindows;
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
use bevy::winit::{UpdateMode, WinitSettings};
|
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};
|
use solitaire_engine::{CoreGamePlugin, SyncProvider, register_theme_asset_sources};
|
||||||
|
|
||||||
fn load_settings() -> Settings {
|
fn load_settings() -> Settings {
|
||||||
@@ -49,6 +52,12 @@ pub fn run() {
|
|||||||
// and any debugger attached still sees the panic).
|
// and any debugger attached still sees the panic).
|
||||||
install_crash_log_hook();
|
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.
|
// Initialise the platform keyring store before any token operations.
|
||||||
// On Linux this uses the Secret Service (GNOME Keyring / KWallet); on
|
// On Linux this uses the Secret Service (GNOME Keyring / KWallet); on
|
||||||
// macOS it uses the Keychain; on Windows it uses the Credential store.
|
// macOS it uses the Keychain; on Windows it uses the Credential store.
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ version.workspace = true
|
|||||||
license.workspace = true
|
license.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
test-support = []
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
|
|||||||
@@ -111,6 +111,28 @@ pub struct Card {
|
|||||||
pub face_up: bool,
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -166,4 +188,19 @@ mod tests {
|
|||||||
assert!(Suit::Diamonds.is_red() && Suit::Hearts.is_red());
|
assert!(Suit::Diamonds.is_red() && Suit::Hearts.is_red());
|
||||||
assert!(Suit::Clubs.is_black() && Suit::Spades.is_black());
|
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
@@ -1,6 +1,11 @@
|
|||||||
use crate::card::{Card, Rank};
|
use crate::card::Card;
|
||||||
use crate::error::MoveError;
|
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 card_game::{Game, Session, SessionConfig};
|
||||||
use klondike::{
|
use klondike::{
|
||||||
DstFoundation, DstTableau, Foundation, Klondike, KlondikeConfig, KlondikeInstruction,
|
DstFoundation, DstTableau, Foundation, Klondike, KlondikeConfig, KlondikeInstruction,
|
||||||
@@ -97,6 +102,7 @@ struct PersistedGameState {
|
|||||||
pub saved_moves: Vec<SavedInstruction>,
|
pub saved_moves: Vec<SavedInstruction>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "test-support")]
|
||||||
/// Test-only override state that shadows the real session pile data.
|
/// Test-only override state that shadows the real session pile data.
|
||||||
///
|
///
|
||||||
/// When `test_pile_state` on `GameState` is `Some`, every pile read method
|
/// When `test_pile_state` on `GameState` is `Some`, every pile read method
|
||||||
@@ -143,8 +149,8 @@ pub struct GameState {
|
|||||||
pub take_from_foundation: bool,
|
pub take_from_foundation: bool,
|
||||||
/// Save-file schema version.
|
/// Save-file schema version.
|
||||||
pub schema_version: u32,
|
pub schema_version: u32,
|
||||||
pub adapter: KlondikeAdapter,
|
|
||||||
pub(crate) session: Session<Klondike>,
|
pub(crate) session: Session<Klondike>,
|
||||||
|
#[cfg(feature = "test-support")]
|
||||||
/// Test pile overrides. Always `None` in production runtime code.
|
/// Test pile overrides. Always `None` in production runtime code.
|
||||||
pub test_pile_state: Option<TestPileState>,
|
pub test_pile_state: Option<TestPileState>,
|
||||||
}
|
}
|
||||||
@@ -165,9 +171,8 @@ impl PartialEq for GameState {
|
|||||||
&& self.schema_version == other.schema_version
|
&& self.schema_version == other.schema_version
|
||||||
&& self.stock_cards() == other.stock_cards()
|
&& self.stock_cards() == other.stock_cards()
|
||||||
&& self.waste_cards() == other.waste_cards()
|
&& self.waste_cards() == other.waste_cards()
|
||||||
&& (0..4_u8).all(|slot| {
|
&& (0..4_u8)
|
||||||
self.foundation_cards(slot).ok() == other.foundation_cards(slot).ok()
|
.all(|slot| self.foundation_cards(slot).ok() == other.foundation_cards(slot).ok())
|
||||||
})
|
|
||||||
&& (0..7_usize).all(|index| {
|
&& (0..7_usize).all(|index| {
|
||||||
let Ok(tableau) = Self::tableau_from_index(index) else {
|
let Ok(tableau) = Self::tableau_from_index(index) else {
|
||||||
return false;
|
return false;
|
||||||
@@ -221,15 +226,15 @@ impl<'de> Deserialize<'de> for GameState {
|
|||||||
recycle_count: persisted.recycle_count,
|
recycle_count: persisted.recycle_count,
|
||||||
take_from_foundation: persisted.take_from_foundation,
|
take_from_foundation: persisted.take_from_foundation,
|
||||||
schema_version: persisted.schema_version,
|
schema_version: persisted.schema_version,
|
||||||
adapter: KlondikeAdapter::new(persisted.draw_mode, persisted.take_from_foundation),
|
|
||||||
session: Self::new_session(persisted.seed, persisted.draw_mode),
|
session: Self::new_session(persisted.seed, persisted.draw_mode),
|
||||||
|
#[cfg(feature = "test-support")]
|
||||||
test_pile_state: None,
|
test_pile_state: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let replay_config = Self::replay_config(game.draw_mode);
|
let replay_config = Self::replay_config(game.draw_mode);
|
||||||
for saved in persisted.saved_moves {
|
for saved in persisted.saved_moves {
|
||||||
let instruction = KlondikeInstruction::try_from(saved)
|
let instruction =
|
||||||
.map_err(serde::de::Error::custom)?;
|
KlondikeInstruction::try_from(saved).map_err(serde::de::Error::custom)?;
|
||||||
if !game
|
if !game
|
||||||
.session
|
.session
|
||||||
.state()
|
.state()
|
||||||
@@ -271,8 +276,8 @@ impl GameState {
|
|||||||
recycle_count: 0,
|
recycle_count: 0,
|
||||||
take_from_foundation: true,
|
take_from_foundation: true,
|
||||||
schema_version: GAME_STATE_SCHEMA_VERSION,
|
schema_version: GAME_STATE_SCHEMA_VERSION,
|
||||||
adapter: KlondikeAdapter::new(draw_mode, true),
|
|
||||||
session: Self::new_session(seed, draw_mode),
|
session: Self::new_session(seed, draw_mode),
|
||||||
|
#[cfg(feature = "test-support")]
|
||||||
test_pile_state: None,
|
test_pile_state: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -290,15 +295,11 @@ impl GameState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn replay_config(draw_mode: DrawMode) -> KlondikeConfig {
|
fn replay_config(draw_mode: DrawMode) -> KlondikeConfig {
|
||||||
KlondikeAdapter::new(draw_mode, true)
|
KlondikeAdapter::config_for(draw_mode, true)
|
||||||
.klondike_config()
|
|
||||||
.clone()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validation_config(&self) -> KlondikeConfig {
|
fn validation_config(&self) -> KlondikeConfig {
|
||||||
KlondikeAdapter::new(self.draw_mode, self.take_from_foundation)
|
KlondikeAdapter::config_for(self.draw_mode, self.take_from_foundation)
|
||||||
.klondike_config()
|
|
||||||
.clone()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn saved_moves(&self) -> Vec<SavedInstruction> {
|
fn saved_moves(&self) -> Vec<SavedInstruction> {
|
||||||
@@ -309,6 +310,14 @@ impl GameState {
|
|||||||
.collect()
|
.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 {
|
fn u32_from_len(len: usize) -> u32 {
|
||||||
if len > u32::MAX as usize {
|
if len > u32::MAX as usize {
|
||||||
u32::MAX
|
u32::MAX
|
||||||
@@ -332,6 +341,7 @@ impl GameState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn stock_cards(&self) -> Vec<Card> {
|
pub fn stock_cards(&self) -> Vec<Card> {
|
||||||
|
#[cfg(feature = "test-support")]
|
||||||
if let Some(ref state) = self.test_pile_state
|
if let Some(ref state) = self.test_pile_state
|
||||||
&& let Some(ref cards) = state.stock
|
&& let Some(ref cards) = state.stock
|
||||||
{
|
{
|
||||||
@@ -342,6 +352,7 @@ impl GameState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn waste_cards(&self) -> Vec<Card> {
|
pub fn waste_cards(&self) -> Vec<Card> {
|
||||||
|
#[cfg(feature = "test-support")]
|
||||||
if let Some(ref state) = self.test_pile_state
|
if let Some(ref state) = self.test_pile_state
|
||||||
&& let Some(ref cards) = state.waste
|
&& let Some(ref cards) = state.waste
|
||||||
{
|
{
|
||||||
@@ -352,6 +363,7 @@ impl GameState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn pile(&self, pile: KlondikePile) -> Vec<Card> {
|
pub fn pile(&self, pile: KlondikePile) -> Vec<Card> {
|
||||||
|
#[cfg(feature = "test-support")]
|
||||||
if let Some(ref state) = self.test_pile_state {
|
if let Some(ref state) = self.test_pile_state {
|
||||||
match pile {
|
match pile {
|
||||||
KlondikePile::Stock => {
|
KlondikePile::Stock => {
|
||||||
@@ -385,11 +397,17 @@ impl GameState {
|
|||||||
}
|
}
|
||||||
KlondikePile::Tableau(tableau) => {
|
KlondikePile::Tableau(tableau) => {
|
||||||
let mut cards = Self::cards_with_face(
|
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,
|
false,
|
||||||
);
|
);
|
||||||
cards.extend(Self::cards_with_face(
|
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,
|
true,
|
||||||
));
|
));
|
||||||
cards
|
cards
|
||||||
@@ -398,26 +416,11 @@ impl GameState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn tableau_from_index(index: usize) -> Result<Tableau, MoveError> {
|
pub fn tableau_from_index(index: usize) -> Result<Tableau, MoveError> {
|
||||||
match index {
|
adapter_tableau_from_index(index).ok_or(MoveError::InvalidSource)
|
||||||
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),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn foundation_from_slot(slot: u8) -> Result<Foundation, MoveError> {
|
pub fn foundation_from_slot(slot: u8) -> Result<Foundation, MoveError> {
|
||||||
match slot {
|
adapter_foundation_from_slot(slot).ok_or(MoveError::InvalidDestination)
|
||||||
0 => Ok(Foundation::Foundation1),
|
|
||||||
1 => Ok(Foundation::Foundation2),
|
|
||||||
2 => Ok(Foundation::Foundation3),
|
|
||||||
3 => Ok(Foundation::Foundation4),
|
|
||||||
_ => Err(MoveError::InvalidDestination),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn foundation_cards(&self, slot: u8) -> Result<Vec<Card>, MoveError> {
|
pub fn foundation_cards(&self, slot: u8) -> Result<Vec<Card>, MoveError> {
|
||||||
@@ -425,39 +428,65 @@ impl GameState {
|
|||||||
Ok(self.pile(KlondikePile::Foundation(foundation)))
|
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
|
/// Test-support helper: clear all pile overrides so reads come from the
|
||||||
/// underlying klondike session again.
|
/// underlying klondike session again.
|
||||||
|
#[cfg(feature = "test-support")]
|
||||||
pub fn clear_test_pile_overrides(&mut self) {
|
pub fn clear_test_pile_overrides(&mut self) {
|
||||||
self.test_pile_state = None;
|
self.test_pile_state = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test-support helper: override face-down stock cards returned by
|
/// Test-support helper: override face-down stock cards returned by
|
||||||
/// [`Self::stock_cards`].
|
/// [`Self::stock_cards`].
|
||||||
|
#[cfg(feature = "test-support")]
|
||||||
pub fn set_test_stock_cards(&mut self, cards: Vec<Card>) {
|
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);
|
state.stock = Some(cards);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test-support helper: override face-up waste cards returned by
|
/// Test-support helper: override face-up waste cards returned by
|
||||||
/// [`Self::waste_cards`] / `pile(KlondikePile::Stock)`.
|
/// [`Self::waste_cards`] / `pile(KlondikePile::Stock)`.
|
||||||
|
#[cfg(feature = "test-support")]
|
||||||
pub fn set_test_waste_cards(&mut self, cards: Vec<Card>) {
|
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);
|
state.waste = Some(cards);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test-support helper: override cards for a specific tableau column.
|
/// 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>) {
|
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);
|
state.tableau.insert(tableau, cards);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test-support helper: override cards for a specific foundation pile.
|
/// 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>) {
|
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);
|
state.foundation.insert(foundation, cards);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test-support helper: override cards for a specific pile.
|
/// 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>) {
|
pub fn set_test_pile_cards(&mut self, pile: KlondikePile, cards: Vec<Card>) {
|
||||||
match pile {
|
match pile {
|
||||||
KlondikePile::Stock => {
|
KlondikePile::Stock => {
|
||||||
@@ -479,22 +508,8 @@ impl GameState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn skip_cards_from_usize(skip: usize) -> Result<SkipCards, MoveError> {
|
fn skip_cards_from_usize(skip: usize) -> Result<SkipCards, MoveError> {
|
||||||
match skip {
|
adapter_skip_cards_from_count(skip)
|
||||||
0 => Ok(SkipCards::Skip0),
|
.ok_or_else(|| MoveError::RuleViolation("invalid tableau card count".into()))
|
||||||
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())),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn will_flip_tableau_source(&self, from: KlondikePile, count: usize) -> bool {
|
fn will_flip_tableau_source(&self, from: KlondikePile, count: usize) -> bool {
|
||||||
@@ -539,9 +554,8 @@ impl GameState {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
(KlondikePile::Foundation(_), KlondikePile::Foundation(_)) => Err(
|
(KlondikePile::Foundation(_), KlondikePile::Foundation(_)) => Err(
|
||||||
MoveError::RuleViolation(
|
MoveError::RuleViolation("cannot move between foundation slots".into()),
|
||||||
"cannot move between foundation slots".into(),
|
),
|
||||||
)),
|
|
||||||
(KlondikePile::Stock, KlondikePile::Tableau(dst)) => {
|
(KlondikePile::Stock, KlondikePile::Tableau(dst)) => {
|
||||||
if count != 1 {
|
if count != 1 {
|
||||||
return Err(MoveError::RuleViolation(
|
return Err(MoveError::RuleViolation(
|
||||||
@@ -595,7 +609,9 @@ impl GameState {
|
|||||||
) -> Option<(KlondikePile, KlondikePile, usize)> {
|
) -> Option<(KlondikePile, KlondikePile, usize)> {
|
||||||
let state = self.session.state().state().state();
|
let state = self.session.state().state().state();
|
||||||
match instruction {
|
match instruction {
|
||||||
KlondikeInstruction::RotateStock => None,
|
KlondikeInstruction::RotateStock => {
|
||||||
|
Some((KlondikePile::Stock, KlondikePile::Stock, 1))
|
||||||
|
}
|
||||||
KlondikeInstruction::DstFoundation(dst_foundation) => {
|
KlondikeInstruction::DstFoundation(dst_foundation) => {
|
||||||
if matches!(dst_foundation.src, KlondikePile::Foundation(_)) {
|
if matches!(dst_foundation.src, KlondikePile::Foundation(_)) {
|
||||||
return None;
|
return None;
|
||||||
@@ -605,12 +621,17 @@ impl GameState {
|
|||||||
KlondikePile::Stock => KlondikePile::Stock,
|
KlondikePile::Stock => KlondikePile::Stock,
|
||||||
KlondikePile::Foundation(_) => return None,
|
KlondikePile::Foundation(_) => return None,
|
||||||
};
|
};
|
||||||
Some((source, KlondikePile::Foundation(dst_foundation.foundation), 1))
|
Some((
|
||||||
|
source,
|
||||||
|
KlondikePile::Foundation(dst_foundation.foundation),
|
||||||
|
1,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
KlondikeInstruction::DstTableau(dst_tableau) => {
|
KlondikeInstruction::DstTableau(dst_tableau) => {
|
||||||
let (source, count) = match dst_tableau.src {
|
let (source, count) = match dst_tableau.src {
|
||||||
KlondikePileStack::Tableau(tableau_stack) => {
|
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)?;
|
let count = face_up_count.checked_sub(tableau_stack.skip_cards as usize)?;
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
return None;
|
return None;
|
||||||
@@ -633,16 +654,15 @@ impl GameState {
|
|||||||
return Err(MoveError::GameAlreadyWon);
|
return Err(MoveError::GameAlreadyWon);
|
||||||
}
|
}
|
||||||
|
|
||||||
let stock_empty = self
|
let stock_empty = self.stock_cards().is_empty();
|
||||||
.stock_cards()
|
|
||||||
.is_empty();
|
|
||||||
let waste_empty = self.waste_cards().is_empty();
|
let waste_empty = self.waste_cards().is_empty();
|
||||||
if stock_empty && waste_empty {
|
if stock_empty && waste_empty {
|
||||||
return Err(MoveError::StockEmpty);
|
return Err(MoveError::StockEmpty);
|
||||||
}
|
}
|
||||||
|
|
||||||
let recycling = stock_empty && !waste_empty;
|
let recycling = stock_empty && !waste_empty;
|
||||||
self.session.process_instruction(KlondikeInstruction::RotateStock);
|
self.session
|
||||||
|
.process_instruction(KlondikeInstruction::RotateStock);
|
||||||
|
|
||||||
if recycling {
|
if recycling {
|
||||||
self.recycle_count = self.recycle_count.saturating_add(1);
|
self.recycle_count = self.recycle_count.saturating_add(1);
|
||||||
@@ -692,9 +712,9 @@ impl GameState {
|
|||||||
return Err(MoveError::RuleViolation("move violates rules".into()));
|
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) {
|
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 {
|
} else {
|
||||||
0
|
0
|
||||||
};
|
};
|
||||||
@@ -743,8 +763,7 @@ impl GameState {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let suit = pile[0].suit;
|
let suit = pile[0].suit;
|
||||||
pile
|
pile.iter()
|
||||||
.iter()
|
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.all(|(i, card)| card.suit == suit && card.rank.value() == i as u8 + 1)
|
.all(|(i, card)| card.suit == suit && card.rank.value() == i as u8 + 1)
|
||||||
}
|
}
|
||||||
@@ -779,18 +798,14 @@ impl GameState {
|
|||||||
self.session
|
self.session
|
||||||
.state()
|
.state()
|
||||||
.state()
|
.state()
|
||||||
.possible_instructions(&config)
|
.get_sorted_moves(&config)
|
||||||
|
.into_iter()
|
||||||
.filter_map(|instruction| self.instruction_to_move(instruction))
|
.filter_map(|instruction| self.instruction_to_move(instruction))
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns `true` when `move_cards(from, to, count)` would currently succeed.
|
/// Returns `true` when `move_cards(from, to, count)` would currently succeed.
|
||||||
pub fn can_move_cards(
|
pub fn can_move_cards(&self, from: &KlondikePile, to: &KlondikePile, count: usize) -> bool {
|
||||||
&self,
|
|
||||||
from: &KlondikePile,
|
|
||||||
to: &KlondikePile,
|
|
||||||
count: usize,
|
|
||||||
) -> bool {
|
|
||||||
if self.is_won || from == to {
|
if self.is_won || from == to {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -838,62 +853,21 @@ impl GameState {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let waste = KlondikePile::Stock;
|
self.possible_instructions()
|
||||||
if let Some(slot) = self
|
.into_iter()
|
||||||
.waste_cards()
|
.find_map(|(from, to, count)| {
|
||||||
.last()
|
if count != 1 {
|
||||||
.and_then(|card| self.foundation_slot_for(card))
|
return None;
|
||||||
{
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
} else if pile.first().map(|c| c.suit) == Some(card.suit) {
|
if matches!(from, KlondikePile::Foundation(_)) {
|
||||||
candidate = Some(slot);
|
return None;
|
||||||
break;
|
}
|
||||||
}
|
if matches!(to, KlondikePile::Foundation(_)) {
|
||||||
}
|
Some((from, to))
|
||||||
let target = candidate.or_else(|| {
|
} else {
|
||||||
if card.rank == Rank::Ace {
|
None
|
||||||
empty_slot
|
}
|
||||||
} else {
|
})
|
||||||
None
|
|
||||||
}
|
|
||||||
});
|
|
||||||
target.filter(|&slot| self.can_place_on_foundation_slot(card, slot))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Time bonus added to score on win: `700_000 / elapsed_seconds` (0 if elapsed is 0).
|
/// Time bonus added to score on win: `700_000 / elapsed_seconds` (0 if elapsed is 0).
|
||||||
@@ -980,7 +954,8 @@ mod tests {
|
|||||||
assert!(
|
assert!(
|
||||||
game.possible_instructions()
|
game.possible_instructions()
|
||||||
.iter()
|
.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());
|
assert!(game.move_cards(from, to, 1).is_err());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
//!
|
//!
|
||||||
//! # Current scope (integration steps 1–4)
|
//! # Current scope (integration steps 1–4)
|
||||||
//!
|
//!
|
||||||
//! [`KlondikeAdapter`] owns the authoritative [`KlondikeConfig`] and exposes
|
//! [`KlondikeAdapter`] is a pure helper namespace for:
|
||||||
//! scoring helpers backed by [`ScoringConfig::DEFAULT`] (Windows XP Standard
|
//! - building [`KlondikeConfig`] from Ferrous settings
|
||||||
//! values). [`GameState`] delegates scoring here so that klondike remains the
|
//! - translating between local and upstream types
|
||||||
//! single source of truth for scoring constants.
|
//! - applying Ferrous-specific scoring policy on top of upstream defaults
|
||||||
//!
|
//!
|
||||||
//! # Not yet implemented
|
//! # 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.
|
/// Bridges `solitaire_core` game config and scoring to the upstream `klondike` crate.
|
||||||
///
|
///
|
||||||
/// Holds a [`KlondikeConfig`] reflecting the current game settings and exposes
|
/// This type is intentionally zero-sized: it does not carry mutable runtime
|
||||||
/// scoring helpers that read from [`ScoringConfig::DEFAULT`] (WXP values).
|
/// state, and exists only as a namespace for configuration, conversion, and
|
||||||
/// [`GameState`] uses this instead of calling `scoring.rs` functions directly.
|
/// scoring helpers.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||||
pub struct KlondikeAdapter {
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl KlondikeAdapter {
|
impl KlondikeAdapter {
|
||||||
/// Create an adapter from the game's draw mode and foundation house-rule setting.
|
/// Build a [`KlondikeConfig`] from draw mode and foundation house-rule setting.
|
||||||
///
|
pub fn config_for(draw_mode: DrawMode, take_from_foundation: bool) -> KlondikeConfig {
|
||||||
/// `take_from_foundation = true` maps to [`MoveFromFoundationConfig::Allowed`];
|
KlondikeConfig {
|
||||||
/// `false` maps to [`MoveFromFoundationConfig::Disallowed`].
|
|
||||||
pub fn new(draw_mode: DrawMode, take_from_foundation: bool) -> Self {
|
|
||||||
let config = KlondikeConfig {
|
|
||||||
draw_stock: match draw_mode {
|
draw_stock: match draw_mode {
|
||||||
DrawMode::DrawOne => DrawStockConfig::DrawOne,
|
DrawMode::DrawOne => DrawStockConfig::DrawOne,
|
||||||
DrawMode::DrawThree => DrawStockConfig::DrawThree,
|
DrawMode::DrawThree => DrawStockConfig::DrawThree,
|
||||||
@@ -67,24 +45,7 @@ impl KlondikeAdapter {
|
|||||||
MoveFromFoundationConfig::Disallowed
|
MoveFromFoundationConfig::Disallowed
|
||||||
},
|
},
|
||||||
scoring: ScoringConfig::DEFAULT,
|
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 ───────────────────────────────────────────────────
|
// ── Scoring helpers ───────────────────────────────────────────────────
|
||||||
@@ -96,8 +57,8 @@ impl KlondikeAdapter {
|
|||||||
/// - Waste → Tableau: +5
|
/// - Waste → Tableau: +5
|
||||||
/// - Foundation → Tableau: −15
|
/// - Foundation → Tableau: −15
|
||||||
/// - All other moves: 0
|
/// - All other moves: 0
|
||||||
pub fn score_for_move(&self, from: &KlondikePile, to: &KlondikePile) -> i32 {
|
pub fn score_for_move(from: &KlondikePile, to: &KlondikePile) -> i32 {
|
||||||
let sc = &self.config.scoring;
|
let sc = ScoringConfig::DEFAULT;
|
||||||
match (from, to) {
|
match (from, to) {
|
||||||
(_, KlondikePile::Foundation(_)) => sc.move_to_foundation,
|
(_, KlondikePile::Foundation(_)) => sc.move_to_foundation,
|
||||||
(KlondikePile::Stock, KlondikePile::Tableau(_)) => sc.move_to_tableau,
|
(KlondikePile::Stock, KlondikePile::Tableau(_)) => sc.move_to_tableau,
|
||||||
@@ -107,8 +68,8 @@ impl KlondikeAdapter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Score delta for exposing a face-down tableau card: +5.
|
/// Score delta for exposing a face-down tableau card: +5.
|
||||||
pub fn score_for_flip(&self) -> i32 {
|
pub fn score_for_flip() -> i32 {
|
||||||
self.config.scoring.flip_up_bonus
|
ScoringConfig::DEFAULT.flip_up_bonus
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Score delta for undo: −15.
|
/// Score delta for undo: −15.
|
||||||
@@ -131,6 +92,12 @@ impl KlondikeAdapter {
|
|||||||
/// | Draw-1 | 1 | −100 |
|
/// | Draw-1 | 1 | −100 |
|
||||||
/// | Draw-3 | 3 | −20 |
|
/// | 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.
|
/// `recycle_count` must be the new total **after** this recycle.
|
||||||
pub fn score_for_recycle(recycle_count: u32, is_draw_three: bool) -> i32 {
|
pub fn score_for_recycle(recycle_count: u32, is_draw_three: bool) -> i32 {
|
||||||
if is_draw_three {
|
if is_draw_three {
|
||||||
@@ -145,20 +112,23 @@ impl KlondikeAdapter {
|
|||||||
/// Score delta for a card move, accounting for game mode.
|
/// Score delta for a card move, accounting for game mode.
|
||||||
///
|
///
|
||||||
/// Returns 0 in [`GameMode::Zen`] (all scoring suppressed).
|
/// Returns 0 in [`GameMode::Zen`] (all scoring suppressed).
|
||||||
pub fn score_for_move_with_mode(
|
pub fn score_for_move_with_mode(from: &KlondikePile, to: &KlondikePile, mode: GameMode) -> i32 {
|
||||||
&self,
|
if mode == GameMode::Zen {
|
||||||
from: &KlondikePile,
|
0
|
||||||
to: &KlondikePile,
|
} else {
|
||||||
mode: GameMode,
|
Self::score_for_move(from, to)
|
||||||
) -> 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.
|
/// Score delta for exposing a face-down card, accounting for game mode.
|
||||||
///
|
///
|
||||||
/// Returns 0 in [`GameMode::Zen`].
|
/// Returns 0 in [`GameMode::Zen`].
|
||||||
pub fn score_for_flip_with_mode(&self, mode: GameMode) -> i32 {
|
pub fn score_for_flip_with_mode(mode: GameMode) -> i32 {
|
||||||
if mode == GameMode::Zen { 0 } else { self.score_for_flip() }
|
if mode == GameMode::Zen {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
Self::score_for_flip()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compute the new score after an undo, accounting for game mode.
|
/// Compute the new score after an undo, accounting for game mode.
|
||||||
@@ -191,13 +161,58 @@ impl KlondikeAdapter {
|
|||||||
|
|
||||||
// ── Type-conversion utilities ─────────────────────────────────────────────
|
// ── 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`].
|
/// Convert [`card_game::Suit`] back to our [`crate::card::Suit`].
|
||||||
pub(crate) fn suit_from_kl(suit: KlSuit) -> crate::card::Suit {
|
pub(crate) fn suit_from_kl(suit: KlSuit) -> crate::card::Suit {
|
||||||
match suit {
|
match suit {
|
||||||
KlSuit::Clubs => crate::card::Suit::Clubs,
|
KlSuit::Clubs => crate::card::Suit::Clubs,
|
||||||
KlSuit::Diamonds => crate::card::Suit::Diamonds,
|
KlSuit::Diamonds => crate::card::Suit::Diamonds,
|
||||||
KlSuit::Hearts => crate::card::Suit::Hearts,
|
KlSuit::Hearts => crate::card::Suit::Hearts,
|
||||||
KlSuit::Spades => crate::card::Suit::Spades,
|
KlSuit::Spades => crate::card::Suit::Spades,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,7 +236,12 @@ pub fn card_from_kl(card: &KlCard) -> crate::card::Card {
|
|||||||
.position(|s| *s == suit)
|
.position(|s| *s == suit)
|
||||||
.expect("suit always in SUITS") as u32;
|
.expect("suit always in SUITS") as u32;
|
||||||
let id = suit_index * 13 + (rank.value() as u32 - 1);
|
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) ──────────────────────────
|
// ── Serde newtypes for KlondikeInstruction (Step 7) ──────────────────────────
|
||||||
@@ -343,7 +363,10 @@ impl From<KlondikePile> for SavedKlondikePile {
|
|||||||
|
|
||||||
impl From<TableauStack> for SavedTableauStack {
|
impl From<TableauStack> for SavedTableauStack {
|
||||||
fn from(ts: TableauStack) -> Self {
|
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 {
|
impl From<DstFoundation> for SavedDstFoundation {
|
||||||
fn from(df: DstFoundation) -> Self {
|
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 {
|
impl From<DstTableau> for SavedDstTableau {
|
||||||
fn from(dt: DstTableau) -> Self {
|
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 {
|
impl TryFrom<SavedTableau> for Tableau {
|
||||||
type Error = InvalidSavedInstruction;
|
type Error = InvalidSavedInstruction;
|
||||||
fn try_from(s: SavedTableau) -> Result<Self, Self::Error> {
|
fn try_from(s: SavedTableau) -> Result<Self, Self::Error> {
|
||||||
match s.0 {
|
tableau_from_index(s.0 as usize).ok_or(InvalidSavedInstruction::Tableau(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)),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TryFrom<SavedFoundation> for Foundation {
|
impl TryFrom<SavedFoundation> for Foundation {
|
||||||
type Error = InvalidSavedInstruction;
|
type Error = InvalidSavedInstruction;
|
||||||
fn try_from(s: SavedFoundation) -> Result<Self, Self::Error> {
|
fn try_from(s: SavedFoundation) -> Result<Self, Self::Error> {
|
||||||
match s.0 {
|
foundation_from_slot(s.0).ok_or(InvalidSavedInstruction::Foundation(s.0))
|
||||||
0 => Ok(Foundation::Foundation1),
|
|
||||||
1 => Ok(Foundation::Foundation2),
|
|
||||||
2 => Ok(Foundation::Foundation3),
|
|
||||||
3 => Ok(Foundation::Foundation4),
|
|
||||||
n => Err(InvalidSavedInstruction::Foundation(n)),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TryFrom<SavedSkipCards> for SkipCards {
|
impl TryFrom<SavedSkipCards> for SkipCards {
|
||||||
type Error = InvalidSavedInstruction;
|
type Error = InvalidSavedInstruction;
|
||||||
fn try_from(s: SavedSkipCards) -> Result<Self, Self::Error> {
|
fn try_from(s: SavedSkipCards) -> Result<Self, Self::Error> {
|
||||||
match s.0 {
|
skip_cards_from_count(s.0 as usize).ok_or(InvalidSavedInstruction::SkipCards(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)),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -459,9 +458,7 @@ impl TryFrom<SavedKlondikePileStack> for KlondikePileStack {
|
|||||||
Ok(match s {
|
Ok(match s {
|
||||||
SavedKlondikePileStack::Tableau(ts) => KlondikePileStack::Tableau(ts.try_into()?),
|
SavedKlondikePileStack::Tableau(ts) => KlondikePileStack::Tableau(ts.try_into()?),
|
||||||
SavedKlondikePileStack::Stock => KlondikePileStack::Stock,
|
SavedKlondikePileStack::Stock => KlondikePileStack::Stock,
|
||||||
SavedKlondikePileStack::Foundation(f) => {
|
SavedKlondikePileStack::Foundation(f) => KlondikePileStack::Foundation(f.try_into()?),
|
||||||
KlondikePileStack::Foundation(f.try_into()?)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -469,14 +466,20 @@ impl TryFrom<SavedKlondikePileStack> for KlondikePileStack {
|
|||||||
impl TryFrom<SavedDstFoundation> for DstFoundation {
|
impl TryFrom<SavedDstFoundation> for DstFoundation {
|
||||||
type Error = InvalidSavedInstruction;
|
type Error = InvalidSavedInstruction;
|
||||||
fn try_from(s: SavedDstFoundation) -> Result<Self, Self::Error> {
|
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 {
|
impl TryFrom<SavedDstTableau> for DstTableau {
|
||||||
type Error = InvalidSavedInstruction;
|
type Error = InvalidSavedInstruction;
|
||||||
fn try_from(s: SavedDstTableau) -> Result<Self, Self::Error> {
|
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()?,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,18 +49,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn pile_top_returns_last_card() {
|
fn pile_top_returns_last_card() {
|
||||||
let mut pile = Pile::new(KlondikePile::Stock);
|
let mut pile = Pile::new(KlondikePile::Stock);
|
||||||
pile.cards.push(Card {
|
pile.cards.push(Card::face_up(0, Suit::Hearts, Rank::Ace));
|
||||||
id: 0,
|
pile.cards.push(Card::face_up(1, Suit::Clubs, Rank::Two));
|
||||||
suit: Suit::Hearts,
|
|
||||||
rank: Rank::Ace,
|
|
||||||
face_up: true,
|
|
||||||
});
|
|
||||||
pile.cards.push(Card {
|
|
||||||
id: 1,
|
|
||||||
suit: Suit::Clubs,
|
|
||||||
rank: Rank::Two,
|
|
||||||
face_up: true,
|
|
||||||
});
|
|
||||||
assert_eq!(pile.top().unwrap().id, 1);
|
assert_eq!(pile.top().unwrap().id, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,30 +69,15 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn claimed_suit_is_none_for_non_foundation() {
|
fn claimed_suit_is_none_for_non_foundation() {
|
||||||
let mut pile = Pile::new(KlondikePile::Tableau(klondike::Tableau::Tableau1));
|
let mut pile = Pile::new(KlondikePile::Tableau(klondike::Tableau::Tableau1));
|
||||||
pile.cards.push(Card {
|
pile.cards.push(Card::face_up(0, Suit::Hearts, Rank::Ace));
|
||||||
id: 0,
|
|
||||||
suit: Suit::Hearts,
|
|
||||||
rank: Rank::Ace,
|
|
||||||
face_up: true,
|
|
||||||
});
|
|
||||||
assert!(pile.claimed_suit().is_none());
|
assert!(pile.claimed_suit().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn claimed_suit_returns_bottom_card_suit() {
|
fn claimed_suit_returns_bottom_card_suit() {
|
||||||
let mut pile = Pile::new(KlondikePile::Foundation(klondike::Foundation::Foundation3));
|
let mut pile = Pile::new(KlondikePile::Foundation(klondike::Foundation::Foundation3));
|
||||||
pile.cards.push(Card {
|
pile.cards.push(Card::face_up(0, Suit::Hearts, Rank::Ace));
|
||||||
id: 0,
|
pile.cards.push(Card::face_up(1, Suit::Hearts, Rank::Two));
|
||||||
suit: Suit::Hearts,
|
|
||||||
rank: Rank::Ace,
|
|
||||||
face_up: true,
|
|
||||||
});
|
|
||||||
pile.cards.push(Card {
|
|
||||||
id: 1,
|
|
||||||
suit: Suit::Hearts,
|
|
||||||
rank: Rank::Two,
|
|
||||||
face_up: true,
|
|
||||||
});
|
|
||||||
assert_eq!(pile.claimed_suit(), Some(Suit::Hearts));
|
assert_eq!(pile.claimed_suit(), Some(Suit::Hearts));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+71
-168
@@ -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"**
|
//! 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.
|
//! 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::game_state::{DrawMode, GameState};
|
||||||
|
use crate::klondike_adapter::KlondikeAdapter;
|
||||||
use crate::card::Card;
|
|
||||||
use crate::game_state::{DifficultyLevel, DrawMode, GameMode, GameState};
|
|
||||||
|
|
||||||
/// Verdict returned by [`try_solve`].
|
/// Verdict returned by [`try_solve`].
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -59,14 +58,6 @@ pub struct SolveOutcome {
|
|||||||
pub first_move: Option<SolverMove>,
|
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`.
|
/// Tries to solve a fresh Classic-mode game from `seed` + `draw_mode`.
|
||||||
pub fn try_solve(seed: u64, draw_mode: DrawMode, config: &SolverConfig) -> SolverResult {
|
pub fn try_solve(seed: u64, draw_mode: DrawMode, config: &SolverConfig) -> SolverResult {
|
||||||
try_solve_with_first_move(seed, draw_mode, config).result
|
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,
|
first_move: None,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preserve the historical payload contract: winnable verdicts always carry
|
// Preserve the historical payload contract: winnable verdicts always carry
|
||||||
// a first move. An already-won state therefore returns no recommendation.
|
// a first move. An already-won state therefore returns no recommendation.
|
||||||
if initial.is_won {
|
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));
|
let solver_config = SessionConfig {
|
||||||
visited.insert(state_key(initial));
|
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;
|
match solver_session.solve() {
|
||||||
let mut moves_considered: u64 = 0;
|
Ok(Some(solution)) => {
|
||||||
let mut saw_inconclusive = false;
|
let first_move = solution
|
||||||
|
.raw_solution()
|
||||||
let mut stack = vec![DfsFrame {
|
.iter()
|
||||||
state: initial.clone(),
|
.find_map(snapshot_to_solver_move);
|
||||||
moves: candidate_moves(initial),
|
if let Some(first_move) = first_move {
|
||||||
next_index: 0,
|
SolveOutcome {
|
||||||
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 {
|
|
||||||
result: SolverResult::Winnable,
|
result: SolverResult::Winnable,
|
||||||
first_move: Some(first_move),
|
first_move: Some(first_move),
|
||||||
};
|
}
|
||||||
|
} else {
|
||||||
|
SolveOutcome {
|
||||||
|
result: SolverResult::Inconclusive,
|
||||||
|
first_move: None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
stack.pop();
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
Ok(None) => SolveOutcome {
|
||||||
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 {
|
|
||||||
result: SolverResult::Unwinnable,
|
result: SolverResult::Unwinnable,
|
||||||
first_move: None,
|
first_move: None,
|
||||||
}
|
},
|
||||||
|
Err(SolveError::MovesBudgetExceeded | SolveError::StatesBudgetExceeded) => SolveOutcome {
|
||||||
|
result: SolverResult::Inconclusive,
|
||||||
|
first_move: None,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn candidate_moves(game: &GameState) -> Vec<SolverMove> {
|
fn snapshot_to_solver_move(snapshot: &StateSnapshot<Klondike>) -> Option<SolverMove> {
|
||||||
let mut out: Vec<SolverMove> = game
|
let source_state = snapshot.state().state();
|
||||||
.possible_instructions()
|
match *snapshot.instruction() {
|
||||||
.into_iter()
|
KlondikeInstruction::RotateStock => Some(SolverMove {
|
||||||
.map(|(source, dest, count)| SolverMove {
|
|
||||||
source,
|
|
||||||
dest,
|
|
||||||
count,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
if !game.stock_cards().is_empty() || !game.waste_cards().is_empty() {
|
|
||||||
out.push(SolverMove {
|
|
||||||
source: KlondikePile::Stock,
|
source: KlondikePile::Stock,
|
||||||
dest: KlondikePile::Stock,
|
dest: KlondikePile::Stock,
|
||||||
count: 1,
|
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
|
Some(SolverMove {
|
||||||
}
|
source,
|
||||||
|
dest: KlondikePile::Tableau(dst_tableau.tableau),
|
||||||
fn apply_solver_move(game: &GameState, mv: &SolverMove) -> Option<GameState> {
|
count,
|
||||||
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,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
//! All saves go through `filename.json.tmp` → `rename()` so a crash or power
|
//! All saves go through `filename.json.tmp` → `rename()` so a crash or power
|
||||||
//! loss during a write never corrupts the saved data.
|
//! loss during a write never corrupts the saved data.
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use chrono::Utc;
|
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use solitaire_core::game_state::{GAME_STATE_SCHEMA_VERSION, GameState};
|
use solitaire_core::game_state::{GAME_STATE_SCHEMA_VERSION, GameState};
|
||||||
|
|||||||
@@ -12,9 +12,9 @@
|
|||||||
//! without matching on [`SyncBackend`] anywhere else in the codebase.
|
//! without matching on [`SyncBackend`] anywhere else in the codebase.
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use solitaire_sync::{SyncPayload, SyncResponse};
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use solitaire_sync::{ChallengeGoal, LeaderboardEntry};
|
use solitaire_sync::{ChallengeGoal, LeaderboardEntry};
|
||||||
|
use solitaire_sync::{SyncPayload, SyncResponse};
|
||||||
|
|
||||||
use crate::{SyncError, SyncProvider};
|
use crate::{SyncError, SyncProvider};
|
||||||
|
|
||||||
|
|||||||
@@ -52,3 +52,4 @@ web-sys = { version = "0.3", features = ["Storage", "Window"] }
|
|||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
tempfile = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
|
solitaire_core = { workspace = true, features = ["test-support"] }
|
||||||
|
|||||||
@@ -373,9 +373,7 @@ fn play_on_draw(
|
|||||||
// When the stock pile is empty the draw action recycles the waste pile
|
// 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
|
// back to stock. Play the flip sound at half volume to give audible
|
||||||
// feedback that distinguishes a recycle from a normal draw.
|
// feedback that distinguishes a recycle from a normal draw.
|
||||||
let stock_len = game
|
let stock_len = game.as_ref().map_or(1, |g| g.0.stock_cards().len()); // default > 0 → normal draw sound
|
||||||
.as_ref()
|
|
||||||
.map_or(1, |g| g.0.stock_cards().len()); // default > 0 → normal draw sound
|
|
||||||
|
|
||||||
if is_recycle(stock_len) {
|
if is_recycle(stock_len) {
|
||||||
let mut data = lib.flip.clone();
|
let mut data = lib.flip.clone();
|
||||||
|
|||||||
@@ -51,15 +51,15 @@ impl Plugin for AutoCompletePlugin {
|
|||||||
app.init_resource::<AutoCompleteState>()
|
app.init_resource::<AutoCompleteState>()
|
||||||
.add_message::<RequestRedraw>()
|
.add_message::<RequestRedraw>()
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
detect_auto_complete,
|
detect_auto_complete,
|
||||||
on_auto_complete_start,
|
on_auto_complete_start,
|
||||||
drive_auto_complete,
|
drive_auto_complete,
|
||||||
)
|
)
|
||||||
.chain()
|
.chain()
|
||||||
.after(GameMutation),
|
.after(GameMutation),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,14 +83,21 @@ fn detect_auto_complete(
|
|||||||
if game.0.is_auto_completable && !state.active {
|
if game.0.is_auto_completable && !state.active {
|
||||||
state.active = true;
|
state.active = true;
|
||||||
state.cooldown = AUTO_COMPLETE_INITIAL_DELAY;
|
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.
|
/// 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
|
// Zero out the cooldown so drive fires on the next update regardless
|
||||||
// of the initial delay constant.
|
// of the initial delay constant.
|
||||||
app.world_mut()
|
app.world_mut().resource_mut::<AutoCompleteState>().cooldown = 0.0;
|
||||||
.resource_mut::<AutoCompleteState>()
|
|
||||||
.cooldown = 0.0;
|
|
||||||
app.update(); // drive fires the move
|
app.update(); // drive fires the move
|
||||||
|
|
||||||
let events = app.world().resource::<Messages<MoveRequestEvent>>();
|
let events = app.world().resource::<Messages<MoveRequestEvent>>();
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ impl AnimationTuning {
|
|||||||
platform: InputPlatform::Mouse,
|
platform: InputPlatform::Mouse,
|
||||||
duration_scale: 1.0,
|
duration_scale: 1.0,
|
||||||
overshoot_scale: 1.0,
|
overshoot_scale: 1.0,
|
||||||
drag_threshold_px: 4.0,
|
drag_threshold_px: 6.0,
|
||||||
drag_scale: 1.08,
|
drag_scale: 1.08,
|
||||||
hover_scale: 1.04,
|
hover_scale: 1.04,
|
||||||
hover_lerp_speed: 14.0,
|
hover_lerp_speed: 14.0,
|
||||||
|
|||||||
@@ -16,10 +16,9 @@ use bevy::color::Color;
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::sprite::Anchor;
|
use bevy::sprite::Anchor;
|
||||||
use bevy::window::WindowResized;
|
use bevy::window::WindowResized;
|
||||||
|
use klondike::{Foundation, KlondikePile, Tableau};
|
||||||
use solitaire_core::card::{Card, Rank, Suit};
|
use solitaire_core::card::{Card, Rank, Suit};
|
||||||
use solitaire_core::game_state::{DrawMode, GameState};
|
use solitaire_core::game_state::{DrawMode, GameState};
|
||||||
use klondike::{Foundation, KlondikePile, Tableau};
|
|
||||||
|
|
||||||
|
|
||||||
use crate::animation_plugin::{CARD_ANIM_Z_LIFT, CardAnim, EffectiveSlideDuration};
|
use crate::animation_plugin::{CARD_ANIM_Z_LIFT, CardAnim, EffectiveSlideDuration};
|
||||||
use crate::card_animation::CardAnimation;
|
use crate::card_animation::CardAnimation;
|
||||||
@@ -2355,16 +2354,16 @@ fn update_tableau_fan_frac(
|
|||||||
Tableau::Tableau6,
|
Tableau::Tableau6,
|
||||||
Tableau::Tableau7,
|
Tableau::Tableau7,
|
||||||
]
|
]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|tableau| {
|
.map(|tableau| {
|
||||||
game.0
|
game.0
|
||||||
.pile(klondike::KlondikePile::Tableau(tableau))
|
.pile(klondike::KlondikePile::Tableau(tableau))
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|c| c.face_up)
|
.filter(|c| c.face_up)
|
||||||
.count()
|
.count()
|
||||||
})
|
})
|
||||||
.max()
|
.max()
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
|
||||||
let card_h = layout.0.card_size.y;
|
let card_h = layout.0.card_size.y;
|
||||||
let avail = layout.0.available_tableau_height;
|
let avail = layout.0.available_tableau_height;
|
||||||
@@ -2575,8 +2574,7 @@ mod tests {
|
|||||||
"need at least 3 waste cards for this test"
|
"need at least 3 waste cards for this test"
|
||||||
);
|
);
|
||||||
|
|
||||||
let waste_ids: std::collections::HashSet<u32> =
|
let waste_ids: std::collections::HashSet<u32> = waste_pile.iter().map(|c| c.id).collect();
|
||||||
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 layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
let positions = card_positions(&g, &layout);
|
let positions = card_positions(&g, &layout);
|
||||||
@@ -2628,8 +2626,7 @@ mod tests {
|
|||||||
let count = waste_pile.len();
|
let count = waste_pile.len();
|
||||||
assert!(count >= 2, "need at least 2 waste cards");
|
assert!(count >= 2, "need at least 2 waste cards");
|
||||||
|
|
||||||
let waste_ids: std::collections::HashSet<u32> =
|
let waste_ids: std::collections::HashSet<u32> = waste_pile.iter().map(|c| c.id).collect();
|
||||||
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 layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
let positions = card_positions(&g, &layout);
|
let positions = card_positions(&g, &layout);
|
||||||
|
|
||||||
|
|||||||
@@ -331,7 +331,11 @@ fn update_drop_target_overlays(
|
|||||||
/// for everything else it is card-sized. Replicated here rather than
|
/// for everything else it is card-sized. Replicated here rather than
|
||||||
/// imported because `pile_drop_rect` is private to `input_plugin` and
|
/// imported because `pile_drop_rect` is private to `input_plugin` and
|
||||||
/// this overlay is the only other consumer.
|
/// 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()?;
|
let centre = layout.pile_positions.get(pile).copied()?;
|
||||||
if matches!(pile, KlondikePile::Tableau(_)) {
|
if matches!(pile, KlondikePile::Tableau(_)) {
|
||||||
let card_count = game.pile(*pile).len();
|
let card_count = game.pile(*pile).len();
|
||||||
@@ -619,7 +623,7 @@ mod tests {
|
|||||||
drag.committed = true;
|
drag.committed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn drop_target_overlay_does_not_spawn_for_invalid_destination() {
|
fn drop_target_overlay_does_not_spawn_for_invalid_destination() {
|
||||||
// 5 of Spades (black) onto Tableau(2)'s 6 of Clubs (also black)
|
// 5 of Spades (black) onto Tableau(2)'s 6 of Clubs (also black)
|
||||||
// — same colour family, illegal. Tableau(2) must NOT be
|
// — 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:?}"
|
"Tableau(2) must not be highlighted for an illegal drop, got {overlays:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,10 +13,10 @@
|
|||||||
|
|
||||||
use bevy::input::ButtonInput;
|
use bevy::input::ButtonInput;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use chrono::{DateTime, Duration, Local, NaiveDate, Utc};
|
|
||||||
use solitaire_data::{daily_seed_for, save_progress_to};
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
|
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"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use solitaire_sync::ChallengeGoal;
|
use solitaire_sync::ChallengeGoal;
|
||||||
|
|
||||||
@@ -354,7 +354,6 @@ fn check_date_rollover(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
|||||||
@@ -849,8 +849,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn shake_anim_skipped_under_reduce_motion() {
|
fn shake_anim_skipped_under_reduce_motion() {
|
||||||
use bevy::ecs::message::Messages;
|
use bevy::ecs::message::Messages;
|
||||||
use solitaire_core::game_state::{DrawMode, GameState};
|
|
||||||
use klondike::Tableau;
|
use klondike::Tableau;
|
||||||
|
use solitaire_core::game_state::{DrawMode, GameState};
|
||||||
use solitaire_data::Settings;
|
use solitaire_data::Settings;
|
||||||
|
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ use chrono::Utc;
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
|
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
|
||||||
use bevy::window::AppLifecycle;
|
use bevy::window::AppLifecycle;
|
||||||
use solitaire_core::game_state::{DrawMode, GameMode, GameState};
|
|
||||||
use klondike::KlondikePile;
|
use klondike::KlondikePile;
|
||||||
|
use solitaire_core::game_state::{DrawMode, GameMode, GameState};
|
||||||
use solitaire_core::solver::{SolverConfig, SolverResult, try_solve};
|
use solitaire_core::solver::{SolverConfig, SolverResult, try_solve};
|
||||||
#[allow(deprecated)]
|
#[allow(deprecated)]
|
||||||
use solitaire_data::latest_replay_path;
|
use solitaire_data::latest_replay_path;
|
||||||
@@ -521,10 +521,7 @@ fn handle_new_game(
|
|||||||
// hides that information and reads naturally as "dealt from the
|
// hides that information and reads naturally as "dealt from the
|
||||||
// deck." Skipped when LayoutResource isn't present (headless tests).
|
// deck." Skipped when LayoutResource isn't present (headless tests).
|
||||||
if let Some(layout) = layout.as_ref()
|
if let Some(layout) = layout.as_ref()
|
||||||
&& let Some(stock) = layout
|
&& let Some(stock) = layout.0.pile_positions.get(&klondike::KlondikePile::Stock)
|
||||||
.0
|
|
||||||
.pile_positions
|
|
||||||
.get(&klondike::KlondikePile::Stock)
|
|
||||||
{
|
{
|
||||||
for mut tx in &mut card_transforms {
|
for mut tx in &mut card_transforms {
|
||||||
tx.translation.x = stock.x;
|
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
|
/// previous heuristic incorrectly did (Quat hit this with 4 cards
|
||||||
/// remaining and the game just sat there).
|
/// remaining and the game just sat there).
|
||||||
pub fn has_legal_moves(game: &GameState) -> bool {
|
pub fn has_legal_moves(game: &GameState) -> bool {
|
||||||
|
|
||||||
|
|
||||||
// Drawing from a non-empty stock, and recycling a non-empty waste back to
|
// Drawing from a non-empty stock, and recycling a non-empty waste back to
|
||||||
// stock, are always legal moves in standard Klondike (unlimited recycles).
|
// stock, are always legal moves in standard Klondike (unlimited recycles).
|
||||||
// A game can only be genuinely stuck when both stock AND waste are exhausted.
|
// A game can only be genuinely stuck when both stock AND waste are exhausted.
|
||||||
let stock_empty = game
|
let stock_empty = game.stock_cards().is_empty();
|
||||||
.stock_cards()
|
let waste_empty = game.waste_cards().is_empty();
|
||||||
.is_empty();
|
|
||||||
let waste_empty = game
|
|
||||||
.waste_cards()
|
|
||||||
.is_empty();
|
|
||||||
if !stock_empty || !waste_empty {
|
if !stock_empty || !waste_empty {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1191,7 +1182,10 @@ fn handle_game_over_input(
|
|||||||
|
|
||||||
if keys.just_pressed(KeyCode::KeyN) || keys.just_pressed(KeyCode::Escape) {
|
if keys.just_pressed(KeyCode::KeyN) || keys.just_pressed(KeyCode::Escape) {
|
||||||
// confirmed: true — the game is already stuck; no abandon-confirmation needed.
|
// 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) {
|
} else if keys.just_pressed(KeyCode::KeyU) {
|
||||||
for entity in &screens {
|
for entity in &screens {
|
||||||
commands.entity(entity).despawn();
|
commands.entity(entity).despawn();
|
||||||
@@ -1219,7 +1213,10 @@ fn handle_game_over_button_input(
|
|||||||
}
|
}
|
||||||
if new_game_buttons.iter().any(|i| *i == Interaction::Pressed) {
|
if new_game_buttons.iter().any(|i| *i == Interaction::Pressed) {
|
||||||
// confirmed: true — the game is already stuck; no abandon-confirmation needed.
|
// 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) {
|
} else if undo_buttons.iter().any(|i| *i == Interaction::Pressed) {
|
||||||
for entity in &screens {
|
for entity in &screens {
|
||||||
commands.entity(entity).despawn();
|
commands.entity(entity).despawn();
|
||||||
@@ -1388,9 +1385,11 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn new_game_request_reseeds() {
|
fn new_game_request_reseeds() {
|
||||||
let mut app = test_app(1);
|
let mut app = test_app(1);
|
||||||
let before: Vec<u32> = app.world().resource::<GameStateResource>().0.pile(KlondikePile::Tableau(
|
let before: Vec<u32> = app
|
||||||
Tableau::Tableau1,
|
.world()
|
||||||
))
|
.resource::<GameStateResource>()
|
||||||
|
.0
|
||||||
|
.pile(KlondikePile::Tableau(Tableau::Tableau1))
|
||||||
.iter()
|
.iter()
|
||||||
.map(|c| c.id)
|
.map(|c| c.id)
|
||||||
.collect();
|
.collect();
|
||||||
@@ -1402,9 +1401,11 @@ mod tests {
|
|||||||
});
|
});
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let after: Vec<u32> = app.world().resource::<GameStateResource>().0.pile(KlondikePile::Tableau(
|
let after: Vec<u32> = app
|
||||||
Tableau::Tableau1,
|
.world()
|
||||||
))
|
.resource::<GameStateResource>()
|
||||||
|
.0
|
||||||
|
.pile(KlondikePile::Tableau(Tableau::Tableau1))
|
||||||
.iter()
|
.iter()
|
||||||
.map(|c| c.id)
|
.map(|c| c.id)
|
||||||
.collect();
|
.collect();
|
||||||
@@ -1415,17 +1416,25 @@ mod tests {
|
|||||||
fn settings_changed_updates_take_from_foundation_flag() {
|
fn settings_changed_updates_take_from_foundation_flag() {
|
||||||
let mut app = test_app(1);
|
let mut app = test_app(1);
|
||||||
assert!(
|
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",
|
"fresh game should inherit default take_from_foundation=true",
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut settings = solitaire_data::Settings::default();
|
let mut settings = solitaire_data::Settings::default();
|
||||||
settings.take_from_foundation = false;
|
settings.take_from_foundation = false;
|
||||||
app.world_mut()
|
app.world_mut()
|
||||||
.write_message(crate::settings_plugin::SettingsChangedEvent(settings.clone()));
|
.write_message(crate::settings_plugin::SettingsChangedEvent(
|
||||||
|
settings.clone(),
|
||||||
|
));
|
||||||
app.update();
|
app.update();
|
||||||
assert!(
|
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",
|
"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));
|
.write_message(crate::settings_plugin::SettingsChangedEvent(settings));
|
||||||
app.update();
|
app.update();
|
||||||
assert!(
|
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",
|
"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
|
/// The timer is pre-seeded just past the threshold and the test
|
||||||
/// re-arms it before each `app.update()` in a small bounded loop:
|
/// 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.
|
// Build a tableau with two face-up cards.
|
||||||
{
|
{
|
||||||
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
|
let mut gs = app.world_mut().resource_mut::<GameStateResource>();
|
||||||
gs.0.set_test_tableau_cards(Tableau::Tableau1, vec![
|
gs.0.set_test_tableau_cards(
|
||||||
Card {
|
Tableau::Tableau1,
|
||||||
id: 910,
|
vec![
|
||||||
suit: Suit::Clubs,
|
Card {
|
||||||
rank: Rank::King,
|
id: 910,
|
||||||
face_up: true,
|
suit: Suit::Clubs,
|
||||||
},
|
rank: Rank::King,
|
||||||
Card {
|
face_up: true,
|
||||||
id: 911,
|
},
|
||||||
suit: Suit::Hearts,
|
Card {
|
||||||
rank: Rank::Queen,
|
id: 911,
|
||||||
face_up: true,
|
suit: Suit::Hearts,
|
||||||
},
|
rank: Rank::Queen,
|
||||||
]);
|
face_up: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
gs.0.set_test_tableau_cards(
|
gs.0.set_test_tableau_cards(
|
||||||
Tableau::Tableau2,
|
Tableau::Tableau2,
|
||||||
vec![Card {
|
vec![Card {
|
||||||
@@ -1782,7 +1797,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn has_legal_moves_detects_non_top_face_up_card_as_source() {
|
fn has_legal_moves_detects_non_top_face_up_card_as_source() {
|
||||||
// Regression: the bug only checked t.cards.last() (top face-up card).
|
// 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
|
// 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
|
/// action-hint strings so players understand why the overlay appeared and
|
||||||
/// what keys to press.
|
/// what keys to press.
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Task #56 — Escape dismisses GameOverScreen and starts new game
|
// Task #56 — Escape dismisses GameOverScreen and starts new game
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
/// Pressing Escape while `GameOverScreen` is visible must fire
|
/// Pressing Escape while `GameOverScreen` is visible must fire
|
||||||
/// `NewGameRequestEvent` — identical behaviour to pressing N.
|
/// `NewGameRequestEvent` — identical behaviour to pressing N.
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Task #48 — Undo with empty stack fires InfoToastEvent
|
// 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
|
/// When a King lands on a foundation that already holds Ace through
|
||||||
/// Queen, exactly one `FoundationCompletedEvent` must fire and carry
|
/// Queen, exactly one `FoundationCompletedEvent` must fire and carry
|
||||||
/// the matching slot + suit.
|
/// 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
|
/// `FoundationCompletedEvent`, even if the source tableau happened
|
||||||
/// to have been a King.
|
/// to have been a King.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2051,7 +2066,7 @@ mod tests {
|
|||||||
/// At 12 cards on a foundation (Ace–Jack on the pile, Queen in
|
/// At 12 cards on a foundation (Ace–Jack on the pile, Queen in
|
||||||
/// flight), the event must NOT fire — the flourish is only for the
|
/// flight), the event must NOT fire — the flourish is only for the
|
||||||
/// final 13th completion.
|
/// final 13th completion.
|
||||||
/// A successful undo must NOT fire an `InfoToastEvent`.
|
/// A successful undo must NOT fire an `InfoToastEvent`.
|
||||||
#[test]
|
#[test]
|
||||||
fn undo_after_draw_does_not_fire_info_toast() {
|
fn undo_after_draw_does_not_fire_info_toast() {
|
||||||
let mut app = test_app(42);
|
let mut app = test_app(42);
|
||||||
@@ -2086,7 +2101,7 @@ mod tests {
|
|||||||
/// Drive a fresh game through a draw + a tableau→foundation move,
|
/// Drive a fresh game through a draw + a tableau→foundation move,
|
||||||
/// then assert the recording resource captured both, in order, with
|
/// then assert the recording resource captured both, in order, with
|
||||||
/// the correct shape.
|
/// 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".
|
/// "what successfully happened", not "what was requested".
|
||||||
#[test]
|
#[test]
|
||||||
fn replay_does_not_record_rejected_moves() {
|
fn replay_does_not_record_rejected_moves() {
|
||||||
@@ -2359,7 +2374,10 @@ mod tests {
|
|||||||
Tableau::Tableau7,
|
Tableau::Tableau7,
|
||||||
] {
|
] {
|
||||||
assert_eq!(
|
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)),
|
expected.pile(KlondikePile::Tableau(tableau)),
|
||||||
"tableau column {tableau:?} must match the unfiltered seed",
|
"tableau column {tableau:?} must match the unfiltered seed",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2404,7 +2404,10 @@ fn update_selection_hud(
|
|||||||
/// When the slot has a claimed suit (any card has landed) the announcement is
|
/// 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
|
/// "▶ {Suit} Foundation"; while the slot is empty it falls back to a
|
||||||
/// "▶ Foundation N" placeholder labelled by the 1-based slot index.
|
/// "▶ 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
|
let claimed = game
|
||||||
.pile(KlondikePile::Foundation(slot))
|
.pile(KlondikePile::Foundation(slot))
|
||||||
.first()
|
.first()
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ use bevy::input::touch::{TouchInput, TouchPhase, Touches};
|
|||||||
use bevy::math::{Vec2, Vec3};
|
use bevy::math::{Vec2, Vec3};
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::window::PrimaryWindow;
|
use bevy::window::PrimaryWindow;
|
||||||
use klondike::{Foundation, KlondikePile, Tableau};
|
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
use bevy::window::{MonitorSelection, WindowMode};
|
use bevy::window::{MonitorSelection, WindowMode};
|
||||||
|
use klondike::{Foundation, KlondikePile, Tableau};
|
||||||
use solitaire_core::card::{Card, Suit};
|
use solitaire_core::card::{Card, Suit};
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::GameState;
|
||||||
|
|
||||||
@@ -789,8 +789,9 @@ fn end_drag(
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let target_pos = card_position(&game.0, &layout.0, &origin, stack_index);
|
let target_pos = card_position(&game.0, &layout.0, &origin, stack_index);
|
||||||
if let Some((entity, _, transform)) =
|
if let Some((entity, _, transform)) = card_entities
|
||||||
card_entities.iter().find(|(_, ce, _)| ce.card_id == card_id)
|
.iter()
|
||||||
|
.find(|(_, ce, _)| ce.card_id == card_id)
|
||||||
{
|
{
|
||||||
let drag_pos = transform.translation.truncate();
|
let drag_pos = transform.translation.truncate();
|
||||||
let drag_z = transform.translation.z;
|
let drag_z = transform.translation.z;
|
||||||
@@ -1027,8 +1028,9 @@ fn touch_end_drag(
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let target_pos = card_position(&game.0, &layout.0, &origin, stack_index);
|
let target_pos = card_position(&game.0, &layout.0, &origin, stack_index);
|
||||||
if let Some((entity, _, transform)) =
|
if let Some((entity, _, transform)) = card_entities
|
||||||
card_entities.iter().find(|(_, ce, _)| ce.card_id == card_id)
|
.iter()
|
||||||
|
.find(|(_, ce, _)| ce.card_id == card_id)
|
||||||
{
|
{
|
||||||
let drag_pos = transform.translation.truncate();
|
let drag_pos = transform.translation.truncate();
|
||||||
let drag_z = transform.translation.z;
|
let drag_z = transform.translation.z;
|
||||||
@@ -1060,6 +1062,13 @@ fn touch_end_drag(
|
|||||||
// Helpers
|
// 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(
|
fn cursor_world(
|
||||||
windows: &Query<&Window, With<PrimaryWindow>>,
|
windows: &Query<&Window, With<PrimaryWindow>>,
|
||||||
cameras: &Query<(&Camera, &GlobalTransform)>,
|
cameras: &Query<(&Camera, &GlobalTransform)>,
|
||||||
@@ -1073,6 +1082,9 @@ fn cursor_world(
|
|||||||
/// Converts a touch screen position (logical pixels, top-left origin) to
|
/// Converts a touch screen position (logical pixels, top-left origin) to
|
||||||
/// world-space 2-D coordinates using the primary camera.
|
/// 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.
|
/// Returns `None` if no camera is present or the projection fails.
|
||||||
fn touch_to_world(cameras: &Query<(&Camera, &GlobalTransform)>, screen_pos: Vec2) -> Option<Vec2> {
|
fn touch_to_world(cameras: &Query<(&Camera, &GlobalTransform)>, screen_pos: Vec2) -> Option<Vec2> {
|
||||||
let (camera, camera_transform) = cameras.single().ok()?;
|
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`
|
/// 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
|
/// exactly; any drift creates an offset between the visible card face and
|
||||||
/// where clicks land.
|
/// 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];
|
let base = layout.pile_positions[pile];
|
||||||
if matches!(pile, KlondikePile::Tableau(_)) {
|
if matches!(pile, KlondikePile::Tableau(_)) {
|
||||||
let mut y_offset = 0.0_f32;
|
let mut y_offset = 0.0_f32;
|
||||||
@@ -1436,6 +1453,7 @@ fn handle_double_tap(
|
|||||||
mut touch_selection: Option<ResMut<TouchSelectionState>>,
|
mut touch_selection: Option<ResMut<TouchSelectionState>>,
|
||||||
mut moves: MessageWriter<MoveRequestEvent>,
|
mut moves: MessageWriter<MoveRequestEvent>,
|
||||||
mut rejected: MessageWriter<MoveRejectedEvent>,
|
mut rejected: MessageWriter<MoveRejectedEvent>,
|
||||||
|
mut toast: MessageWriter<InfoToastEvent>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
mut card_sprites: Query<(Entity, &CardEntity, &mut Sprite)>,
|
mut card_sprites: Query<(Entity, &CardEntity, &mut Sprite)>,
|
||||||
) {
|
) {
|
||||||
@@ -1509,8 +1527,9 @@ fn handle_double_tap(
|
|||||||
sel.clear();
|
sel.clear();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// First tap: select the source.
|
// First tap: select the source, then nudge the player.
|
||||||
sel.set(*tapped_pile, drag.cards.clone());
|
sel.set(*tapped_pile, drag.cards.clone());
|
||||||
|
toast.write(InfoToastEvent("Tap a pile to move".into()));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1540,8 +1559,12 @@ fn handle_double_tap(
|
|||||||
if drag.cards.len() > 1 {
|
if drag.cards.len() > 1 {
|
||||||
let stack_index = pile_cards.len() - drag.cards.len();
|
let stack_index = pile_cards.len() - drag.cards.len();
|
||||||
if let Some(bottom_card) = pile_cards.get(stack_index)
|
if let Some(bottom_card) = pile_cards.get(stack_index)
|
||||||
&& let Some((dest, count)) =
|
&& let Some((dest, count)) = best_tableau_destination_for_stack(
|
||||||
best_tableau_destination_for_stack(bottom_card, tapped_pile, &game.0, drag.cards.len())
|
bottom_card,
|
||||||
|
tapped_pile,
|
||||||
|
&game.0,
|
||||||
|
drag.cards.len(),
|
||||||
|
)
|
||||||
{
|
{
|
||||||
for (entity, ce, mut sprite) in card_sprites.iter_mut() {
|
for (entity, ce, mut sprite) in card_sprites.iter_mut() {
|
||||||
if drag.cards.contains(&ce.card_id) {
|
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
|
/// 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
|
/// upstream `klondike` priorities are preserved.
|
||||||
/// stock" appended last when the stock is non-empty and nothing else is
|
|
||||||
/// available.
|
|
||||||
///
|
///
|
||||||
/// Each entry is `(from, to, count)` — the same triple used by
|
/// Each entry is `(from, to, count)` — the same triple used by
|
||||||
/// [`MoveRequestEvent`]. The list may be empty when no move exists at all
|
/// [`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
|
/// This is the backing data for the cycling hint system: the H key steps
|
||||||
/// through `hints[HintCycleIndex % hints.len()]` on each press.
|
/// through `hints[HintCycleIndex % hints.len()]` on each press.
|
||||||
pub fn all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile, usize)> {
|
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 sources: Vec<KlondikePile> = {
|
||||||
let mut s = vec![KlondikePile::Stock];
|
let mut s = vec![KlondikePile::Stock];
|
||||||
for tableau in tableaus() {
|
for tableau in tableaus() {
|
||||||
@@ -1818,7 +1856,8 @@ mod tests {
|
|||||||
// face-up card, but the iterator should skip face-down cards and
|
// 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
|
// the cursor sits above the face-up card's AABB, so the result
|
||||||
// is None.
|
// 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);
|
let result = find_draggable_at(face_down_pos, &game, &layout);
|
||||||
assert!(result.is_none(), "face-down cards should not be draggable");
|
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
|
// 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
|
// sits at base.y - 6 * TABLEAU_FACEDOWN_FAN_FRAC * card_h, NOT at
|
||||||
// base.y - 6 * TABLEAU_FAN_FRAC * card_h. Click the centre.
|
// 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)
|
let result = find_draggable_at(face_up_pos, &game, &layout)
|
||||||
.expect("clicking the face-up card's visible centre must initiate a drag");
|
.expect("clicking the face-up card's visible centre must initiate a drag");
|
||||||
assert_eq!(result.0, KlondikePile::Tableau(Tableau::Tableau7));
|
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
|
// (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
|
// 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.
|
// 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 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");
|
let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
|
||||||
assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau1));
|
assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau1));
|
||||||
@@ -1923,7 +1964,12 @@ mod tests {
|
|||||||
let mut game = game;
|
let mut game = game;
|
||||||
game.set_test_tableau_cards(Tableau::Tableau1, Vec::new());
|
game.set_test_tableau_cards(Tableau::Tableau1, Vec::new());
|
||||||
let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
|
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)));
|
assert_eq!(target, Some(KlondikePile::Tableau(Tableau::Tableau1)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1932,7 +1978,12 @@ mod tests {
|
|||||||
let game = GameState::new(42, DrawMode::DrawOne);
|
let game = GameState::new(42, DrawMode::DrawOne);
|
||||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
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 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);
|
assert_eq!(target, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2012,7 +2063,10 @@ mod tests {
|
|||||||
fn pile_drop_rect_is_card_sized_for_non_tableau() {
|
fn pile_drop_rect_is_card_sized_for_non_tableau() {
|
||||||
let game = GameState::new(42, DrawMode::DrawOne);
|
let game = GameState::new(42, DrawMode::DrawOne);
|
||||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
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);
|
let (_, size) = pile_drop_rect(&pile, &layout, &game);
|
||||||
assert_eq!(size, layout.card_size);
|
assert_eq!(size, layout.card_size);
|
||||||
}
|
}
|
||||||
@@ -2022,7 +2076,7 @@ mod tests {
|
|||||||
// Task #27 — best_destination pure-function tests
|
// Task #27 — best_destination pure-function tests
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn best_destination_returns_none_when_no_legal_move() {
|
fn best_destination_returns_none_when_no_legal_move() {
|
||||||
use solitaire_core::card::{Card, Rank, Suit};
|
use solitaire_core::card::{Card, Rank, Suit};
|
||||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||||
@@ -2044,7 +2098,7 @@ mod tests {
|
|||||||
// best_tableau_destination_for_stack pure-function tests
|
// best_tableau_destination_for_stack pure-function tests
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn best_tableau_destination_for_stack_skips_source_pile() {
|
fn best_tableau_destination_for_stack_skips_source_pile() {
|
||||||
use solitaire_core::card::{Card, Rank, Suit};
|
use solitaire_core::card::{Card, Rank, Suit};
|
||||||
let mut game = GameState::new(1, DrawMode::DrawOne);
|
let mut game = GameState::new(1, DrawMode::DrawOne);
|
||||||
@@ -2070,8 +2124,12 @@ mod tests {
|
|||||||
rank: Rank::King,
|
rank: Rank::King,
|
||||||
face_up: true,
|
face_up: true,
|
||||||
};
|
};
|
||||||
let result =
|
let result = best_tableau_destination_for_stack(
|
||||||
best_tableau_destination_for_stack(&bottom_card, &KlondikePile::Tableau(Tableau::Tableau1), &game, 1);
|
&bottom_card,
|
||||||
|
&KlondikePile::Tableau(Tableau::Tableau1),
|
||||||
|
&game,
|
||||||
|
1,
|
||||||
|
);
|
||||||
// Result must be some other empty tableau column, never the source.
|
// Result must be some other empty tableau column, never the source.
|
||||||
if let Some((dest, _)) = result {
|
if let Some((dest, _)) = result {
|
||||||
assert_ne!(dest, KlondikePile::Tableau(Tableau::Tableau1));
|
assert_ne!(dest, KlondikePile::Tableau(Tableau::Tableau1));
|
||||||
@@ -2103,8 +2161,12 @@ mod tests {
|
|||||||
rank: Rank::Two,
|
rank: Rank::Two,
|
||||||
face_up: true,
|
face_up: true,
|
||||||
};
|
};
|
||||||
let result =
|
let result = best_tableau_destination_for_stack(
|
||||||
best_tableau_destination_for_stack(&bottom_card, &KlondikePile::Tableau(Tableau::Tableau1), &game, 1);
|
&bottom_card,
|
||||||
|
&KlondikePile::Tableau(Tableau::Tableau1),
|
||||||
|
&game,
|
||||||
|
1,
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
result.is_none(),
|
result.is_none(),
|
||||||
"Two of Clubs has no legal tableau destination on empty piles"
|
"Two of Clubs has no legal tableau destination on empty piles"
|
||||||
@@ -2140,7 +2202,7 @@ mod tests {
|
|||||||
assert_eq!(count, 1);
|
assert_eq!(count, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// G key fires ForfeitRequestEvent (modal-based forfeit flow)
|
// G key fires ForfeitRequestEvent (modal-based forfeit flow)
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -2176,11 +2238,11 @@ mod tests {
|
|||||||
clear_test_piles(&mut game);
|
clear_test_piles(&mut game);
|
||||||
// Put one card back into the stock so "draw" is a valid suggestion.
|
// Put one card back into the stock so "draw" is a valid suggestion.
|
||||||
game.set_test_stock_cards(vec![Card {
|
game.set_test_stock_cards(vec![Card {
|
||||||
id: 1,
|
id: 1,
|
||||||
suit: Suit::Clubs,
|
suit: Suit::Clubs,
|
||||||
rank: Rank::Ace,
|
rank: Rank::Ace,
|
||||||
face_up: false,
|
face_up: false,
|
||||||
}]);
|
}]);
|
||||||
|
|
||||||
let hints = all_hints(&game);
|
let hints = all_hints(&game);
|
||||||
assert_eq!(hints.len(), 1, "exactly one hint: draw from stock");
|
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
|
/// `all_hints` must be empty when both stock and waste are empty and no
|
||||||
/// pile-to-pile move exists — the game is truly stuck.
|
/// pile-to-pile move exists — the game is truly stuck.
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Drag-rejection return tween — `CardAnimation` replaces the legacy
|
// Drag-rejection return tween — `CardAnimation` replaces the legacy
|
||||||
// `ShakeAnim` on the dragged cards. The audio cue
|
// `ShakeAnim` on the dragged cards. The audio cue
|
||||||
// (`card_invalid.wav` via `MoveRejectedEvent`) is unchanged; only the
|
// (`card_invalid.wav` via `MoveRejectedEvent`) is unchanged; only the
|
||||||
|
|||||||
@@ -269,7 +269,10 @@ pub fn compute_layout(
|
|||||||
5 => Tableau::Tableau6,
|
5 => Tableau::Tableau6,
|
||||||
_ => Tableau::Tableau7,
|
_ => 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
|
// Adaptive tableau fan fraction. On height-limited (desktop) windows the
|
||||||
@@ -339,7 +342,9 @@ mod tests {
|
|||||||
Tableau::Tableau7,
|
Tableau::Tableau7,
|
||||||
] {
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
layout.pile_positions.contains_key(&KlondikePile::Tableau(tableau)),
|
layout
|
||||||
|
.pile_positions
|
||||||
|
.contains_key(&KlondikePile::Tableau(tableau)),
|
||||||
"missing tableau {tableau:?}"
|
"missing tableau {tableau:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -758,7 +763,11 @@ mod tests {
|
|||||||
let window = Vec2::new(360.0, 800.0);
|
let window = Vec2::new(360.0, 800.0);
|
||||||
let without = compute_layout(window, 0.0, 0.0, true);
|
let without = compute_layout(window, 0.0, 0.0, true);
|
||||||
let with_inset = compute_layout(window, 0.0, 48.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!(
|
assert!(
|
||||||
(without.pile_positions[&pile].x - with_inset.pile_positions[&pile].x).abs() < 1e-3,
|
(without.pile_positions[&pile].x - with_inset.pile_positions[&pile].x).abs() < 1e-3,
|
||||||
"{pile:?} x-position must not change with safe_area_bottom",
|
"{pile:?} x-position must not change with safe_area_bottom",
|
||||||
|
|||||||
@@ -153,7 +153,6 @@ pub use safe_area::{SafeAreaAnchoredTop, SafeAreaInsets, SafeAreaInsetsPlugin};
|
|||||||
pub use selection_plugin::{
|
pub use selection_plugin::{
|
||||||
KeyboardDragState, SelectionHighlight, SelectionPlugin, SelectionState,
|
KeyboardDragState, SelectionHighlight, SelectionPlugin, SelectionState,
|
||||||
};
|
};
|
||||||
pub use touch_selection_plugin::{TouchSelectionPlugin, TouchSelectionState};
|
|
||||||
pub use settings_plugin::{
|
pub use settings_plugin::{
|
||||||
PendingWindowGeometry, SFX_STEP, SettingsChangedEvent, SettingsPlugin, SettingsResource,
|
PendingWindowGeometry, SFX_STEP, SettingsChangedEvent, SettingsPlugin, SettingsResource,
|
||||||
SettingsScreen, WINDOW_GEOMETRY_DEBOUNCE_SECS,
|
SettingsScreen, WINDOW_GEOMETRY_DEBOUNCE_SECS,
|
||||||
@@ -179,6 +178,7 @@ pub use theme::{
|
|||||||
pub use time_attack_plugin::{
|
pub use time_attack_plugin::{
|
||||||
TIME_ATTACK_DURATION_SECS, TimeAttackEndedEvent, TimeAttackPlugin, TimeAttackResource,
|
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_focus::{Disabled, FocusGroup, Focusable, FocusedButton, UiFocusPlugin};
|
||||||
pub use ui_modal::{
|
pub use ui_modal::{
|
||||||
ButtonVariant, ModalActions, ModalBody, ModalButton, ModalCard, ModalHeader, ModalScrim,
|
ButtonVariant, ModalActions, ModalBody, ModalButton, ModalCard, ModalHeader, ModalScrim,
|
||||||
|
|||||||
@@ -26,8 +26,8 @@
|
|||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
|
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
|
||||||
use solitaire_core::game_state::GameState;
|
|
||||||
use klondike::KlondikePile;
|
use klondike::KlondikePile;
|
||||||
|
use solitaire_core::game_state::GameState;
|
||||||
use solitaire_core::solver::{SolverConfig, SolverResult, try_solve_from_state};
|
use solitaire_core::solver::{SolverConfig, SolverResult, try_solve_from_state};
|
||||||
|
|
||||||
use crate::card_plugin::CardEntity;
|
use crate::card_plugin::CardEntity;
|
||||||
@@ -101,7 +101,10 @@ struct HintTask {
|
|||||||
enum HintTaskOutput {
|
enum HintTaskOutput {
|
||||||
/// Solver verdict was `Winnable`; here is the first move on the
|
/// Solver verdict was `Winnable`; here is the first move on the
|
||||||
/// solution path.
|
/// solution path.
|
||||||
SolverMove { from: KlondikePile, to: KlondikePile },
|
SolverMove {
|
||||||
|
from: KlondikePile,
|
||||||
|
to: KlondikePile,
|
||||||
|
},
|
||||||
/// Solver was `Unwinnable` or `Inconclusive`. The poll system
|
/// Solver was `Unwinnable` or `Inconclusive`. The poll system
|
||||||
/// runs the legacy heuristic against the live `GameState` so the
|
/// runs the legacy heuristic against the live `GameState` so the
|
||||||
/// H key always produces feedback while any legal move exists.
|
/// H key always produces feedback while any legal move exists.
|
||||||
|
|||||||
@@ -329,7 +329,12 @@ pub fn find_top_face_up_card_at(
|
|||||||
/// Mirror of `input_plugin::card_position` — kept private to this
|
/// Mirror of `input_plugin::card_position` — kept private to this
|
||||||
/// module so the radial's hit-test geometry tracks renderer geometry
|
/// module so the radial's hit-test geometry tracks renderer geometry
|
||||||
/// without depending on `input_plugin` internals.
|
/// 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];
|
let base = layout.pile_positions[pile];
|
||||||
if matches!(pile, KlondikePile::Tableau(_)) {
|
if matches!(pile, KlondikePile::Tableau(_)) {
|
||||||
let mut y_offset = 0.0_f32;
|
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.
|
/// 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 count = dests.len();
|
||||||
|
let margin = RADIAL_ICON_SIZE_PX / 2.0;
|
||||||
dests
|
dests
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(i, d)| {
|
.map(|(i, d)| {
|
||||||
(
|
let raw = radial_anchor_for_index(centre, count, i, RADIAL_RADIUS_PX);
|
||||||
d,
|
let clamped = Vec2::new(
|
||||||
radial_anchor_for_index(centre, count, i, RADIAL_RADIUS_PX),
|
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()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -472,7 +488,12 @@ fn radial_open_on_right_click(
|
|||||||
});
|
});
|
||||||
return;
|
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 {
|
*state = RightClickRadialState::Active {
|
||||||
source_pile,
|
source_pile,
|
||||||
@@ -498,6 +519,7 @@ fn radial_open_on_long_press(
|
|||||||
drag: Res<DragState>,
|
drag: Res<DragState>,
|
||||||
paused: Option<Res<PausedResource>>,
|
paused: Option<Res<PausedResource>>,
|
||||||
touches: Option<Res<Touches>>,
|
touches: Option<Res<Touches>>,
|
||||||
|
windows: Query<&Window, With<PrimaryWindow>>,
|
||||||
cameras: Query<(&Camera, &GlobalTransform)>,
|
cameras: Query<(&Camera, &GlobalTransform)>,
|
||||||
layout: Option<Res<LayoutResource>>,
|
layout: Option<Res<LayoutResource>>,
|
||||||
game: Option<Res<GameStateResource>>,
|
game: Option<Res<GameStateResource>>,
|
||||||
@@ -540,7 +562,12 @@ fn radial_open_on_long_press(
|
|||||||
if dests.is_empty() {
|
if dests.is_empty() {
|
||||||
return;
|
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 {
|
*state = RightClickRadialState::Active {
|
||||||
source_pile,
|
source_pile,
|
||||||
count: 1,
|
count: 1,
|
||||||
@@ -958,7 +985,8 @@ mod tests {
|
|||||||
rank: Rank::Ace,
|
rank: Rank::Ace,
|
||||||
face_up: true,
|
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
|
// Ace can be placed on every empty foundation. We only need
|
||||||
// the count to be ≥ 1 and the source pile to be excluded.
|
// the count to be ≥ 1 and the source pile to be excluded.
|
||||||
assert!(
|
assert!(
|
||||||
@@ -977,7 +1005,11 @@ mod tests {
|
|||||||
rank: Rank::Ace,
|
rank: Rank::Ace,
|
||||||
face_up: true,
|
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)));
|
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
|
/// Pressing right-click on a face-up card with at least one legal
|
||||||
/// destination must transition the state to `Active` carrying the
|
/// destination must transition the state to `Active` carrying the
|
||||||
/// expected source / count / legal-destination set.
|
/// 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.
|
/// icon must fire a `MoveRequestEvent` and return the state to Idle.
|
||||||
#[test]
|
#[test]
|
||||||
fn right_click_release_over_destination_fires_move_request() {
|
fn right_click_release_over_destination_fires_move_request() {
|
||||||
|
|||||||
@@ -97,7 +97,11 @@ pub(crate) fn format_move_body(m: &ReplayMove) -> String {
|
|||||||
match m {
|
match m {
|
||||||
ReplayMove::StockClick => "stock cycle".to_string(),
|
ReplayMove::StockClick => "stock cycle".to_string(),
|
||||||
ReplayMove::Move { from, to, .. } => {
|
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)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,15 +25,14 @@
|
|||||||
|
|
||||||
mod format;
|
mod format;
|
||||||
mod input;
|
mod input;
|
||||||
mod update;
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
mod update;
|
||||||
|
|
||||||
pub(crate) use self::format::*;
|
pub(crate) use self::format::*;
|
||||||
pub(crate) use self::input::*;
|
pub(crate) use self::input::*;
|
||||||
pub(crate) use self::update::*;
|
pub(crate) use self::update::*;
|
||||||
|
|
||||||
use bevy::prelude::*;
|
|
||||||
use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent, UndoRequestEvent};
|
use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent, UndoRequestEvent};
|
||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
use crate::platform::SHOW_KEYBOARD_ACCELERATORS;
|
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,
|
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,
|
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.
|
// Z-index — see `ui_theme::Z_MODAL_SCRIM` (200) for the next layer above.
|
||||||
@@ -316,7 +316,6 @@ pub struct ReplayOverlayScrubNotch;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct ReplayOverlayScrubNotchLabel;
|
pub struct ReplayOverlayScrubNotchLabel;
|
||||||
|
|
||||||
|
|
||||||
/// Marker on the keybind-hint footer row at the bottom edge of the
|
/// Marker on the keybind-hint footer row at the bottom edge of the
|
||||||
/// banner. Carries two `Text` children: a vim-style mode indicator
|
/// banner. Carries two `Text` children: a vim-style mode indicator
|
||||||
/// (`▌ NORMAL │ replay`) on the left and the keybind hint
|
/// (`▌ 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);
|
let frac = (idx as f32 / total as f32).clamp(0.0, 1.0);
|
||||||
Some(frac * 100.0)
|
Some(frac * 100.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -854,8 +854,7 @@ fn scrub_notch_labels_carry_helper_strings() {
|
|||||||
|
|
||||||
let mut texts = scrub_notch_label_texts(&mut app);
|
let mut texts = scrub_notch_label_texts(&mut app);
|
||||||
texts.sort();
|
texts.sort();
|
||||||
let mut expected: Vec<String> =
|
let mut expected: Vec<String> = scrub_notch_labels().iter().map(|s| s.to_string()).collect();
|
||||||
scrub_notch_labels().iter().map(|s| s.to_string()).collect();
|
|
||||||
expected.sort();
|
expected.sort();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
texts, expected,
|
texts, expected,
|
||||||
@@ -1106,10 +1105,22 @@ fn move_log_active_row_text(app: &mut App) -> String {
|
|||||||
#[test]
|
#[test]
|
||||||
fn format_pile_uses_one_indexed_lowercase_names() {
|
fn format_pile_uses_one_indexed_lowercase_names() {
|
||||||
assert_eq!(format_pile(&KlondikePile::Stock), "waste");
|
assert_eq!(format_pile(&KlondikePile::Stock), "waste");
|
||||||
assert_eq!(format_pile(&KlondikePile::Foundation(Foundation::Foundation1)), "foundation 1");
|
assert_eq!(
|
||||||
assert_eq!(format_pile(&KlondikePile::Foundation(Foundation::Foundation3)), "foundation 3");
|
format_pile(&KlondikePile::Foundation(Foundation::Foundation1)),
|
||||||
assert_eq!(format_pile(&KlondikePile::Tableau(Tableau::Tableau1)), "tableau 1");
|
"foundation 1"
|
||||||
assert_eq!(format_pile(&KlondikePile::Tableau(Tableau::Tableau7)), "tableau 7");
|
);
|
||||||
|
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
|
/// Move-body formatter renders `StockClick` as a label and
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
|
||||||
use super::*;
|
|
||||||
use super::format::{
|
use super::format::{
|
||||||
format_active_move_row, format_foundations_row, format_kth_next_row,
|
format_active_move_row, format_foundations_row, format_kth_next_row, format_kth_recent_row,
|
||||||
format_kth_recent_row, format_move_log_header, format_progress, format_stock_waste_row,
|
format_move_log_header, format_progress, format_stock_waste_row,
|
||||||
};
|
};
|
||||||
|
use super::*;
|
||||||
use crate::layout::LayoutResource;
|
use crate::layout::LayoutResource;
|
||||||
use crate::replay_playback::ReplayPlaybackState;
|
use crate::replay_playback::ReplayPlaybackState;
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
|
|||||||
@@ -268,11 +268,12 @@ pub fn step_replay_playback(
|
|||||||
}
|
}
|
||||||
match &replay.moves[*cursor] {
|
match &replay.moves[*cursor] {
|
||||||
ReplayMove::Move { from, to, count } => {
|
ReplayMove::Move { from, to, count } => {
|
||||||
let (Ok(from), Ok(to)) = (
|
let (Ok(from), Ok(to)) = (KlondikePile::try_from(*from), KlondikePile::try_from(*to))
|
||||||
KlondikePile::try_from(*from),
|
else {
|
||||||
KlondikePile::try_from(*to),
|
warn!(
|
||||||
) else {
|
"skipping replay move with invalid pile encoding at cursor {}",
|
||||||
warn!("skipping replay move with invalid pile encoding at cursor {}", *cursor);
|
*cursor
|
||||||
|
);
|
||||||
*cursor += 1;
|
*cursor += 1;
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
@@ -379,10 +380,9 @@ fn tick_replay_playback(
|
|||||||
while *secs_to_next <= 0.0 && *cursor < replay.moves.len() {
|
while *secs_to_next <= 0.0 && *cursor < replay.moves.len() {
|
||||||
match &replay.moves[*cursor] {
|
match &replay.moves[*cursor] {
|
||||||
ReplayMove::Move { from, to, count } => {
|
ReplayMove::Move { from, to, count } => {
|
||||||
if let (Ok(from), Ok(to)) = (
|
if let (Ok(from), Ok(to)) =
|
||||||
KlondikePile::try_from(*from),
|
(KlondikePile::try_from(*from), KlondikePile::try_from(*to))
|
||||||
KlondikePile::try_from(*to),
|
{
|
||||||
) {
|
|
||||||
moves_writer.write(MoveRequestEvent {
|
moves_writer.write(MoveRequestEvent {
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ use std::sync::Arc;
|
|||||||
use bevy::math::Vec2;
|
use bevy::math::Vec2;
|
||||||
use bevy::prelude::Resource;
|
use bevy::prelude::Resource;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use solitaire_core::game_state::GameState;
|
|
||||||
use klondike::KlondikePile;
|
use klondike::KlondikePile;
|
||||||
|
use solitaire_core::game_state::GameState;
|
||||||
|
|
||||||
/// Wraps the currently active `GameState`. Single source of truth for the in-progress game.
|
/// Wraps the currently active `GameState`. Single source of truth for the in-progress game.
|
||||||
#[derive(Resource, Debug, Clone)]
|
#[derive(Resource, Debug, Clone)]
|
||||||
|
|||||||
@@ -253,24 +253,24 @@ mod android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resets the inset poller and clears cached insets on
|
/// Resets the inset poller on `AppLifecycle::WillResume` so that
|
||||||
/// `AppLifecycle::WillResume` so that `refresh_insets` re-queries JNI in the
|
/// `refresh_insets` re-queries JNI in the frames immediately after the app
|
||||||
/// frames immediately after the app returns to the foreground.
|
/// returns to the foreground.
|
||||||
///
|
///
|
||||||
/// Clearing `SafeAreaInsets` to the default (all-zero) fires
|
/// The cached `SafeAreaInsets` are intentionally **not** zeroed here.
|
||||||
/// `on_safe_area_changed` in `table_plugin`, which emits a synthetic
|
/// Zeroing them would cause two layout recomputes on every resume:
|
||||||
/// `WindowResized`. `on_window_resized` then recomputes the layout;
|
/// once with zero insets (wrong position) and again when JNI resolves the
|
||||||
/// once `refresh_insets` resolves the real values a second synthetic
|
/// real values — visible as a flash. By preserving the last-known values
|
||||||
/// `WindowResized` fires and the layout converges to the correct position.
|
/// 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(
|
pub(super) fn rearm_on_resumed(
|
||||||
mut lifecycle: MessageReader<AppLifecycle>,
|
mut lifecycle: MessageReader<AppLifecycle>,
|
||||||
mut poll: ResMut<SafeAreaPollTries>,
|
mut poll: ResMut<SafeAreaPollTries>,
|
||||||
mut insets: ResMut<SafeAreaInsets>,
|
|
||||||
) {
|
) {
|
||||||
for event in lifecycle.read() {
|
for event in lifecycle.read() {
|
||||||
if matches!(event, AppLifecycle::WillResume) {
|
if matches!(event, AppLifecycle::WillResume) {
|
||||||
poll.0 = 0;
|
poll.0 = 0;
|
||||||
*insets = SafeAreaInsets::default();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -202,7 +202,10 @@ fn cycled_piles() -> Vec<KlondikePile> {
|
|||||||
///
|
///
|
||||||
/// If `current` is `None` the first available pile is returned.
|
/// If `current` is `None` the first available pile is returned.
|
||||||
/// If `available` is empty, `None` 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() {
|
if available.is_empty() {
|
||||||
return None;
|
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
|
/// Both `current` and `next` must be `Some`; if either is `None` this returns
|
||||||
/// `false`.
|
/// `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 {
|
let (Some(cur), Some(nxt)) = (current, next) else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
@@ -386,9 +393,7 @@ fn handle_selection_keys(
|
|||||||
KlondikePile::Tableau(Tableau::Tableau7),
|
KlondikePile::Tableau(Tableau::Tableau7),
|
||||||
];
|
];
|
||||||
all.into_iter()
|
all.into_iter()
|
||||||
.filter(|p| {
|
.filter(|p| pile_cards(&game.0, p).last().is_some_and(|c| c.face_up))
|
||||||
pile_cards(&game.0, p).last().is_some_and(|c| c.face_up)
|
|
||||||
})
|
|
||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -717,10 +722,7 @@ fn update_selection_highlight(
|
|||||||
|
|
||||||
/// Returns the top face-up card on `pile`, or `None` if the pile is
|
/// Returns the top face-up card on `pile`, or `None` if the pile is
|
||||||
/// empty or its top card is face-down.
|
/// empty or its top card is face-down.
|
||||||
fn top_face_up_card(
|
fn top_face_up_card(pile: &KlondikePile, game: &GameState) -> Option<Card> {
|
||||||
pile: &KlondikePile,
|
|
||||||
game: &GameState,
|
|
||||||
) -> Option<Card> {
|
|
||||||
pile_cards(game, pile).last().filter(|c| c.face_up).cloned()
|
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.
|
// DragState must mirror the lifted cards and carry the keyboard sentinel.
|
||||||
let drag = app.world().resource::<DragState>();
|
let drag = app.world().resource::<DragState>();
|
||||||
assert_eq!(drag.cards, vec![100]);
|
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));
|
assert_eq!(drag.active_touch_id, Some(KEYBOARD_DRAG_TOUCH_ID));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test 3 — Arrow keys in `Lifted` cycle through *legal* destinations
|
/// Test 3 — Arrow keys in `Lifted` cycle through *legal* destinations
|
||||||
/// only (foundations and tableaus that pass `can_place_on_*`), and
|
/// only (foundations and tableaus that pass `can_place_on_*`), and
|
||||||
/// wrap at the end of the list.
|
/// 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
|
/// exactly one `MoveRequestEvent` and resets the state machine to
|
||||||
/// `Idle` with `DragState` cleared.
|
/// `Idle` with `DragState` cleared.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -33,9 +33,9 @@ use crate::events::{
|
|||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
use crate::progress_plugin::ProgressResource;
|
use crate::progress_plugin::ProgressResource;
|
||||||
use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource};
|
use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource};
|
||||||
use crate::theme::{ThemeThumbnailCache, ThemeThumbnailPair};
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use crate::theme::{ImportError, import_theme, refresh_registry};
|
use crate::theme::{ImportError, import_theme, refresh_registry};
|
||||||
|
use crate::theme::{ThemeThumbnailCache, ThemeThumbnailPair};
|
||||||
use crate::ui_focus::{FocusGroup, FocusRow, Focusable, FocusedButton};
|
use crate::ui_focus::{FocusGroup, FocusRow, Focusable, FocusedButton};
|
||||||
use crate::ui_modal::{
|
use crate::ui_modal::{
|
||||||
ButtonVariant, ModalButton, ModalScrim, spawn_modal, spawn_modal_actions, spawn_modal_button,
|
ButtonVariant, ModalButton, ModalScrim, spawn_modal, spawn_modal_actions, spawn_modal_button,
|
||||||
|
|||||||
@@ -22,9 +22,9 @@
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use bevy::log::warn;
|
use bevy::log::warn;
|
||||||
use bevy::prelude::{App, Plugin, Resource};
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use bevy::prelude::Startup;
|
use bevy::prelude::Startup;
|
||||||
|
use bevy::prelude::{App, Plugin, Resource};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use super::ThemeMeta;
|
use super::ThemeMeta;
|
||||||
|
|||||||
@@ -77,8 +77,9 @@ impl TouchSelectionState {
|
|||||||
|
|
||||||
/// Marker component placed on the highlight sprite child of a selected source card.
|
/// Marker component placed on the highlight sprite child of a selected source card.
|
||||||
///
|
///
|
||||||
/// Despawned and respawned each frame by [`update_touch_selection_highlight`] so
|
/// Despawned and respawned by [`update_touch_selection_highlight`] whenever
|
||||||
/// stale highlights never linger after a game-state change.
|
/// [`TouchSelectionState`] changes. The system is gated on `is_changed()` so it
|
||||||
|
/// is a no-op every frame that the selection is stable.
|
||||||
#[derive(Component)]
|
#[derive(Component)]
|
||||||
pub struct TouchSelectionHighlight;
|
pub struct TouchSelectionHighlight;
|
||||||
|
|
||||||
@@ -91,16 +92,15 @@ pub struct TouchSelectionPlugin;
|
|||||||
|
|
||||||
impl Plugin for TouchSelectionPlugin {
|
impl Plugin for TouchSelectionPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.init_resource::<TouchSelectionState>()
|
app.init_resource::<TouchSelectionState>().add_systems(
|
||||||
.add_systems(
|
Update,
|
||||||
Update,
|
(
|
||||||
(
|
clear_touch_selection_on_state_change,
|
||||||
clear_touch_selection_on_state_change,
|
update_touch_selection_highlight,
|
||||||
update_touch_selection_highlight,
|
)
|
||||||
)
|
.chain()
|
||||||
.chain()
|
.after(GameMutation),
|
||||||
.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.
|
/// Maintains the `TouchSelectionHighlight` outline sprite on the selected source card.
|
||||||
///
|
///
|
||||||
/// All existing `TouchSelectionHighlight` entities are despawned each frame and
|
/// Rebuilds the highlight set only when [`TouchSelectionState`] or the layout
|
||||||
/// a new one is spawned on the top card of the selected pile (if any). This
|
/// actually changes — not every frame. Existing highlights are despawned first,
|
||||||
/// matches the pattern used by `selection_plugin::update_selection_highlight`.
|
/// then a fresh highlight is spawned on every card in the selected stack.
|
||||||
pub(crate) fn update_touch_selection_highlight(
|
pub(crate) fn update_touch_selection_highlight(
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
selection: Res<TouchSelectionState>,
|
selection: Res<TouchSelectionState>,
|
||||||
@@ -131,6 +131,12 @@ pub(crate) fn update_touch_selection_highlight(
|
|||||||
highlights: Query<Entity, With<TouchSelectionHighlight>>,
|
highlights: Query<Entity, With<TouchSelectionHighlight>>,
|
||||||
layout: Option<Res<LayoutResource>>,
|
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.
|
// Despawn stale highlights first.
|
||||||
for entity in &highlights {
|
for entity in &highlights {
|
||||||
commands.entity(entity).despawn();
|
commands.entity(entity).despawn();
|
||||||
|
|||||||
@@ -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_app/src/main.rs && \
|
||||||
echo "fn main() {}" > solitaire_assetgen/src/main.rs
|
echo "fn main() {}" > solitaire_assetgen/src/main.rs
|
||||||
|
|
||||||
# The Quaternions registry requires authentication. CI passes CI_TOKEN as a
|
# Registry config comes from .cargo/config.toml copied above.
|
||||||
# build secret so it never appears in image layers or docker history.
|
RUN cargo fetch --locked
|
||||||
RUN --mount=type=secret,id=cargo_token,required=true \
|
|
||||||
CARGO_REGISTRIES_QUATERNIONS_TOKEN="Bearer $(cat /run/secrets/cargo_token)" \
|
|
||||||
cargo fetch --locked
|
|
||||||
|
|
||||||
# Now copy real source and build in release mode.
|
# Now copy real source and build in release mode.
|
||||||
COPY solitaire_core/src ./solitaire_core/src
|
COPY solitaire_core/src ./solitaire_core/src
|
||||||
@@ -51,9 +48,7 @@ COPY solitaire_server/migrations ./solitaire_server/migrations
|
|||||||
COPY .sqlx ./.sqlx
|
COPY .sqlx ./.sqlx
|
||||||
|
|
||||||
ENV SQLX_OFFLINE=true
|
ENV SQLX_OFFLINE=true
|
||||||
RUN --mount=type=secret,id=cargo_token,required=true \
|
RUN cargo build --release --locked -p solitaire_server --bin solitaire_server
|
||||||
CARGO_REGISTRIES_QUATERNIONS_TOKEN="Bearer $(cat /run/secrets/cargo_token)" \
|
|
||||||
cargo build --release --locked -p solitaire_server --bin solitaire_server
|
|
||||||
|
|
||||||
# --- Runtime stage ---
|
# --- Runtime stage ---
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
|
|||||||
@@ -249,11 +249,11 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
|
|||||||
|
|
||||||
const CSP: &str = concat!(
|
const CSP: &str = concat!(
|
||||||
"default-src 'self'; ",
|
"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'; ",
|
"style-src 'self' 'unsafe-inline'; ",
|
||||||
"font-src 'self'; ",
|
"font-src 'self'; ",
|
||||||
"img-src 'self' data:; ",
|
"img-src 'self' data: https://analytics.aleshym.co; ",
|
||||||
"connect-src 'self'; ",
|
"connect-src 'self' https://analytics.aleshym.co; ",
|
||||||
"object-src 'none'; ",
|
"object-src 'none'; ",
|
||||||
"frame-ancestors 'none'",
|
"frame-ancestors 'none'",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -196,14 +196,11 @@ async fn update_leaderboard_if_opted_in(
|
|||||||
user_id: &str,
|
user_id: &str,
|
||||||
payload: &SyncPayload,
|
payload: &SyncPayload,
|
||||||
) -> Result<(), AppError> {
|
) -> Result<(), AppError> {
|
||||||
let opted_in = sqlx::query!(
|
let opted_in = sqlx::query!("SELECT leaderboard_opt_in FROM users WHERE id = ?", user_id)
|
||||||
"SELECT leaderboard_opt_in FROM users WHERE id = ?",
|
.fetch_optional(pool)
|
||||||
user_id
|
.await?
|
||||||
)
|
.map(|r| r.leaderboard_opt_in)
|
||||||
.fetch_optional(pool)
|
.unwrap_or(0);
|
||||||
.await?
|
|
||||||
.map(|r| r.leaderboard_opt_in)
|
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
if opted_in != 1 {
|
if opted_in != 1 {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
|||||||
+256
-18
@@ -176,9 +176,12 @@ async function bootstrap() {
|
|||||||
if (saved) {
|
if (saved) {
|
||||||
showResumeDialog(saved);
|
showResumeDialog(saved);
|
||||||
} else {
|
} else {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const urlSeed = params.has("seed") ? Number(params.get("seed")) : randomSeed();
|
const rawSeed = Number(params.get("seed"));
|
||||||
drawThree = params.has("draw3");
|
const urlSeed = params.has("seed") && Number.isFinite(rawSeed) && rawSeed > 0
|
||||||
|
? Math.floor(rawSeed)
|
||||||
|
: randomSeed();
|
||||||
|
drawThree = params.has("draw3");
|
||||||
chkDraw3.checked = drawThree;
|
chkDraw3.checked = drawThree;
|
||||||
startGame(urlSeed);
|
startGame(urlSeed);
|
||||||
}
|
}
|
||||||
@@ -393,8 +396,16 @@ function render(s) {
|
|||||||
stopTimer();
|
stopTimer();
|
||||||
if (acTimer) { clearInterval(acTimer); acTimer = null; }
|
if (acTimer) { clearInterval(acTimer); acTimer = null; }
|
||||||
if (noMovesBanner) noMovesBanner.classList.add("hidden");
|
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 {
|
} 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();
|
saveState();
|
||||||
const noMoves = !s.has_moves && !s.is_auto_completable;
|
const noMoves = !s.has_moves && !s.is_auto_completable;
|
||||||
if (noMovesBanner) noMovesBanner.classList.toggle("hidden", !noMoves);
|
if (noMovesBanner) noMovesBanner.classList.toggle("hidden", !noMoves);
|
||||||
@@ -429,20 +440,34 @@ function showWin(s) {
|
|||||||
submitReplay(s);
|
submitReplay(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitReplay(s) {
|
function buildReplayPayload(s) {
|
||||||
const token = localStorage.getItem('fs_token');
|
if (!game || !s) return null;
|
||||||
if (!token) return;
|
let moves;
|
||||||
const payload = {
|
try {
|
||||||
schema_version: 1,
|
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()),
|
seed: Math.round(game.seed()),
|
||||||
draw_mode: drawThree ? "DrawThree" : "DrawOne",
|
draw_mode: drawThree ? "DrawThree" : "DrawOne",
|
||||||
mode: "Classic",
|
mode: "Classic",
|
||||||
time_seconds: elapsedSecs,
|
time_seconds: Math.max(1, elapsedSecs),
|
||||||
final_score: s.score,
|
final_score: s.score,
|
||||||
move_count: s.move_count,
|
|
||||||
recorded_at: new Date().toISOString().slice(0, 10),
|
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 {
|
try {
|
||||||
await fetch('/api/replays', {
|
await fetch('/api/replays', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -467,7 +492,12 @@ function flashIllegal(cardIds) {
|
|||||||
for (const id of cardIds) {
|
for (const id of cardIds) {
|
||||||
const el = cardEls.get(id);
|
const el = cardEls.get(id);
|
||||||
if (!el) continue;
|
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.style.setProperty("--card-tx", el.style.transform || "translate(0,0)");
|
||||||
el.classList.add("illegal");
|
el.classList.add("illegal");
|
||||||
el.addEventListener("animationend", () => {
|
el.addEventListener("animationend", () => {
|
||||||
@@ -496,11 +526,34 @@ function attachHandlers() {
|
|||||||
syncThemeButton();
|
syncThemeButton();
|
||||||
if (game) render(game.state());
|
if (game) render(game.state());
|
||||||
});
|
});
|
||||||
|
const doDraw = () => { const r = game.draw(); if (r.ok) render(r.snapshot); };
|
||||||
|
|
||||||
document.addEventListener("keydown", (e) => {
|
document.addEventListener("keydown", (e) => {
|
||||||
if (e.target.tagName === "INPUT") return;
|
const tag = e.target?.tagName;
|
||||||
if (e.key === "z" || e.key === "Z") doUndo();
|
if (e.target?.isContentEditable || tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
|
||||||
if (e.key === "n" || e.key === "N") startGame(randomSeed());
|
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);
|
board.addEventListener("pointerdown", onPointerDown);
|
||||||
@@ -706,7 +759,7 @@ function onPointerCancel() {
|
|||||||
|
|
||||||
// ── Click / dblclick ──────────────────────────────────────────────────────────
|
// ── Click / dblclick ──────────────────────────────────────────────────────────
|
||||||
function onBoardClick(e) {
|
function onBoardClick(e) {
|
||||||
if (drag) return;
|
if (drag || snap?.is_won) return;
|
||||||
const { x: bx, y: by } = boardRelative(e.clientX, e.clientY);
|
const { x: bx, y: by } = boardRelative(e.clientX, e.clientY);
|
||||||
const stock = PILE_ORIGIN.stock;
|
const stock = PILE_ORIGIN.stock;
|
||||||
if (bx >= stock.x && bx <= stock.x + CARD_W && by >= stock.y && by <= stock.y + CARD_H) {
|
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) {
|
function onBoardDblClick(e) {
|
||||||
if (drag) return;
|
if (drag || snap?.is_won) return;
|
||||||
const { x: bx, y: by } = boardRelative(e.clientX, e.clientY);
|
const { x: bx, y: by } = boardRelative(e.clientX, e.clientY);
|
||||||
const hit = hitTestCard(bx, by);
|
const hit = hitTestCard(bx, by);
|
||||||
if (!hit || !hit.card.face_up) return;
|
if (!hit || !hit.card.face_up) return;
|
||||||
@@ -782,6 +835,191 @@ async function loadAvatar() {
|
|||||||
} catch { /* not signed in — avatar stays hidden */ }
|
} 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 ─────────────────────────────────────────────────────────────────────
|
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||||
bootstrap().catch(console.error);
|
bootstrap().catch(console.error);
|
||||||
loadAvatar();
|
loadAvatar();
|
||||||
|
|||||||
Binary file not shown.
@@ -122,6 +122,67 @@ export class SolitaireGame {
|
|||||||
const ret = wasm.solitairegame_auto_complete_step(this.__wbg_ptr);
|
const ret = wasm.solitairegame_auto_complete_step(this.__wbg_ptr);
|
||||||
return ret;
|
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).
|
* Draw from stock to waste (or recycle waste → stock when stock is empty).
|
||||||
* Returns `{ok, error?, snapshot?}`.
|
* Returns `{ok, error?, snapshot?}`.
|
||||||
@@ -182,6 +243,21 @@ export class SolitaireGame {
|
|||||||
SolitaireGameFinalization.register(this, this.__wbg_ptr, this);
|
SolitaireGameFinalization.register(this, this.__wbg_ptr, this);
|
||||||
return 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.
|
* The seed used to deal this game.
|
||||||
* @returns {number}
|
* @returns {number}
|
||||||
|
|||||||
Binary file not shown.
+629
-12
@@ -24,7 +24,9 @@ use serde::{Deserialize, Serialize};
|
|||||||
use solitaire_core::card::Suit;
|
use solitaire_core::card::Suit;
|
||||||
use solitaire_core::error::MoveError;
|
use solitaire_core::error::MoveError;
|
||||||
use solitaire_core::game_state::{DrawMode, GameMode, GameState};
|
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::*;
|
use wasm_bindgen::prelude::*;
|
||||||
|
|
||||||
/// Mirrors the variants of `solitaire_data::ReplayMove` v2 (atomic
|
/// 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.
|
/// 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 struct StateSnapshot {
|
||||||
pub step_idx: usize,
|
pub step_idx: usize,
|
||||||
pub total_steps: 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
|
/// means the card back is drawn; in that case `suit` and `rank` are
|
||||||
/// still set (so the renderer doesn't need separate "unknown" data),
|
/// still set (so the renderer doesn't need separate "unknown" data),
|
||||||
/// just hidden visually.
|
/// just hidden visually.
|
||||||
#[derive(Debug, Clone, Copy, Serialize)]
|
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
|
||||||
pub struct CardSnapshot {
|
pub struct CardSnapshot {
|
||||||
pub id: u32,
|
pub id: u32,
|
||||||
/// `"clubs" | "diamonds" | "hearts" | "spades"`.
|
/// `"clubs" | "diamonds" | "hearts" | "spades"`.
|
||||||
@@ -157,8 +159,9 @@ impl ReplayPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn snapshot(&self) -> StateSnapshot {
|
fn snapshot(&self) -> StateSnapshot {
|
||||||
let pile_cards =
|
let pile_cards = |t: KlondikePile| -> Vec<CardSnapshot> {
|
||||||
|t: KlondikePile| -> Vec<CardSnapshot> { self.game.pile(t).iter().map(CardSnapshot::from).collect() };
|
self.game.pile(t).iter().map(CardSnapshot::from).collect()
|
||||||
|
};
|
||||||
let foundations: [Vec<CardSnapshot>; 4] = [
|
let foundations: [Vec<CardSnapshot>; 4] = [
|
||||||
pile_cards(KlondikePile::Foundation(Foundation::Foundation1)),
|
pile_cards(KlondikePile::Foundation(Foundation::Foundation1)),
|
||||||
pile_cards(KlondikePile::Foundation(Foundation::Foundation2)),
|
pile_cards(KlondikePile::Foundation(Foundation::Foundation2)),
|
||||||
@@ -180,8 +183,18 @@ impl ReplayPlayer {
|
|||||||
score: self.game.score,
|
score: self.game.score,
|
||||||
move_count: self.game.move_count,
|
move_count: self.game.move_count,
|
||||||
is_won: self.game.is_won,
|
is_won: self.game.is_won,
|
||||||
stock: self.game.stock_cards().iter().map(CardSnapshot::from).collect(),
|
stock: self
|
||||||
waste: self.game.waste_cards().iter().map(CardSnapshot::from).collect(),
|
.game
|
||||||
|
.stock_cards()
|
||||||
|
.iter()
|
||||||
|
.map(CardSnapshot::from)
|
||||||
|
.collect(),
|
||||||
|
waste: self
|
||||||
|
.game
|
||||||
|
.waste_cards()
|
||||||
|
.iter()
|
||||||
|
.map(CardSnapshot::from)
|
||||||
|
.collect(),
|
||||||
foundations,
|
foundations,
|
||||||
tableaus,
|
tableaus,
|
||||||
}
|
}
|
||||||
@@ -252,7 +265,7 @@ impl ReplayPlayer {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Full snapshot of a live `SolitaireGame` for the JS renderer.
|
/// Full snapshot of a live `SolitaireGame` for the JS renderer.
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||||
pub struct GameSnapshot {
|
pub struct GameSnapshot {
|
||||||
pub score: i32,
|
pub score: i32,
|
||||||
pub move_count: u32,
|
pub move_count: u32,
|
||||||
@@ -279,6 +292,174 @@ pub struct ActionResult {
|
|||||||
pub snapshot: Option<GameSnapshot>,
|
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.
|
/// Interactive Klondike game backed by the real `solitaire_core` rules engine.
|
||||||
///
|
///
|
||||||
/// Construct with `new(seed, draw_three)`, then call `draw()`, `move_cards()`,
|
/// Construct with `new(seed, draw_three)`, then call `draw()`, `move_cards()`,
|
||||||
@@ -291,8 +472,9 @@ pub struct SolitaireGame {
|
|||||||
|
|
||||||
impl SolitaireGame {
|
impl SolitaireGame {
|
||||||
fn snap(&self) -> GameSnapshot {
|
fn snap(&self) -> GameSnapshot {
|
||||||
let cards =
|
let cards = |t: KlondikePile| -> Vec<CardSnapshot> {
|
||||||
|t: KlondikePile| -> Vec<CardSnapshot> { self.game.pile(t).iter().map(CardSnapshot::from).collect() };
|
self.game.pile(t).iter().map(CardSnapshot::from).collect()
|
||||||
|
};
|
||||||
let has_moves = {
|
let has_moves = {
|
||||||
let stock_empty = self.game.stock_cards().is_empty();
|
let stock_empty = self.game.stock_cards().is_empty();
|
||||||
let waste_empty = self.game.waste_cards().is_empty();
|
let waste_empty = self.game.waste_cards().is_empty();
|
||||||
@@ -306,8 +488,18 @@ impl SolitaireGame {
|
|||||||
has_moves,
|
has_moves,
|
||||||
undo_count: self.game.undo_count,
|
undo_count: self.game.undo_count,
|
||||||
undo_stack_len: self.game.undo_stack_len(),
|
undo_stack_len: self.game.undo_stack_len(),
|
||||||
stock: self.game.stock_cards().iter().map(CardSnapshot::from).collect(),
|
stock: self
|
||||||
waste: self.game.waste_cards().iter().map(CardSnapshot::from).collect(),
|
.game
|
||||||
|
.stock_cards()
|
||||||
|
.iter()
|
||||||
|
.map(CardSnapshot::from)
|
||||||
|
.collect(),
|
||||||
|
waste: self
|
||||||
|
.game
|
||||||
|
.waste_cards()
|
||||||
|
.iter()
|
||||||
|
.map(CardSnapshot::from)
|
||||||
|
.collect(),
|
||||||
foundations: [
|
foundations: [
|
||||||
cards(KlondikePile::Foundation(Foundation::Foundation1)),
|
cards(KlondikePile::Foundation(Foundation::Foundation1)),
|
||||||
cards(KlondikePile::Foundation(Foundation::Foundation2)),
|
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 {
|
fn ok_js(&self) -> JsValue {
|
||||||
serde_wasm_bindgen::to_value(&ActionResult {
|
serde_wasm_bindgen::to_value(&ActionResult {
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -497,4 +821,297 @@ impl SolitaireGame {
|
|||||||
Err(_) => JsValue::NULL,
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user