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