chore: cargo fmt across workspace; add analytics domain to CSP
Build and Deploy / build-and-push (push) Successful in 4m46s

- Apply cargo fmt to solitaire_engine, solitaire_server formatting.
- solitaire_server/src/lib.rs: add https://analytics.aleshym.co to
  script-src, img-src, and connect-src so the analytics beacon loads
  without a CSP violation.
- docs and README updates.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-06-02 12:21:32 -07:00
parent baf524ec75
commit 1cdb78caf2
25 changed files with 229 additions and 130 deletions
+5
View File
@@ -15,6 +15,11 @@ agentdb.rvf.lock
# IDE project files # IDE project files
.idea/ .idea/
# Browser e2e harness artifacts
solitaire_server/e2e/node_modules/
solitaire_server/e2e/playwright-report/
solitaire_server/e2e/test-results/
# Android signing keystores — never commit # Android signing keystores — never commit
*.jks *.jks
*.jks.bak *.jks.bak
+20
View File
@@ -118,8 +118,28 @@ cargo test -p solitaire_core -p solitaire_sync -p solitaire_data -p solitaire_se
# Lint # Lint
cargo clippy --workspace --all-targets -- -D warnings cargo clippy --workspace --all-targets -- -D warnings
# Browser e2e smoke (starts solitaire_server automatically)
cd solitaire_server/e2e
npm ci
npx playwright install chromium
npm test
# Seed-batch cycle regression gate (thresholded)
npm run review:cycles:regression
# Loop-aware candidate benchmark (writes test-results/cycle-candidate.json)
npm run review:cycles:candidate
``` ```
For layered engine-vs-UI automation design (Rust unit tests, wasm debug-API
integration tests, and Playwright UI validation), see
[docs/testing-architecture.md](docs/testing-architecture.md).
For Quaternions (`klondike` / `card_game`) dependency upgrades, use
[`scripts/update_quaternions_deps.sh`](scripts/update_quaternions_deps.sh) and
the runbook in [docs/card-game-integration.md](docs/card-game-integration.md).
## Credits ## Credits
Built on [Bevy](https://bevyengine.org/) and the wider Rust ecosystem Built on [Bevy](https://bevyengine.org/) and the wider Rust ecosystem
+28
View File
@@ -101,6 +101,11 @@ Our 767-line `solitaire_core::solver` reimplements the full game rules to run th
### 5. JSON Serialisation / Persistence ### 5. JSON Serialisation / Persistence
`solitaire_core::GameState` serialises the full mid-game state to JSON via `serde` so the engine can save on exit and restore on launch. `KlondikeState` derives `Clone` + `Eq` + `Hash` but not `Serialize` / `Deserialize`. No upstream changes are needed — this is handled externally. `solitaire_core::GameState` serialises the full mid-game state to JSON via `serde` so the engine can save on exit and restore on launch. `KlondikeState` derives `Clone` + `Eq` + `Hash` but not `Serialize` / `Deserialize`. No upstream changes are needed — this is handled externally.
**Current verification (2026-06-01):** `klondike v0.3.0` and `card_game v0.4.0`
crate manifests expose no `serde` dependency/feature, and source exports no
serde derives for instruction/state snapshot types. Keep Ferrous'
`SavedInstruction` bridge in place.
**Session history:** `StateSnapshot<G>` stores the pre-move game state and instruction. On load, the session is reconstructed from the serialised snapshot history — no full replay from seed needed. **Session history:** `StateSnapshot<G>` stores the pre-move game state and instruction. On load, the session is reconstructed from the serialised snapshot history — no full replay from seed needed.
**In our wrapper:** Serialise the `solitaire_core` wrapper struct using newtypes. Define `SavedInstruction` (a `Serialize + Deserialize` mirror of `KlondikeInstruction`) and `SavedStateSnapshot`. Reconstruct `SessionState` from the deserialised history. Schema version field lives on our wrapper. **In our wrapper:** Serialise the `solitaire_core` wrapper struct using newtypes. Define `SavedInstruction` (a `Serialize + Deserialize` mirror of `KlondikeInstruction`) and `SavedStateSnapshot`. Reconstruct `SessionState` from the deserialised history. Schema version field lives on our wrapper.
@@ -147,6 +152,29 @@ Steps in dependency order. Upstream issues #10, #11, and the solver are all merg
--- ---
## Quaternions Upgrade Runbook
Use this sequence whenever upgrading `klondike` / `card_game` from the
Quaternions registry:
1. Review upstream changes/releases:
- <https://git.aleshym.co/Quaternions/card_game>
- <https://git.aleshym.co/Quaternions/klondike>
2. Run:
```bash
scripts/update_quaternions_deps.sh <klondike_version> <card_game_version>
```
3. If the script passes, inspect the resulting `Cargo.lock` diff and land the
upgrade with the normal PR flow.
The script enforces:
- lockfile update to requested versions
- `cargo test --workspace`
- `cargo clippy --workspace -- -D warnings`
- deterministic replay/debug-API smoke tests in `solitaire_wasm`
---
## What Does NOT Need to Change ## What Does NOT Need to Change
- The `solitaire_engine` Bevy layer — it works against `solitaire_core` types; changes are isolated to `solitaire_core`. - The `solitaire_engine` Bevy layer — it works against `solitaire_core` types; changes are isolated to `solitaire_core`.
+1
View File
@@ -52,3 +52,4 @@ web-sys = { version = "0.3", features = ["Storage", "Window"] }
[dev-dependencies] [dev-dependencies]
async-trait = { workspace = true } async-trait = { workspace = true }
tempfile = { workspace = true } tempfile = { workspace = true }
solitaire_core = { workspace = true, features = ["test-support"] }
+1 -3
View File
@@ -373,9 +373,7 @@ fn play_on_draw(
// When the stock pile is empty the draw action recycles the waste pile // When the stock pile is empty the draw action recycles the waste pile
// back to stock. Play the flip sound at half volume to give audible // back to stock. Play the flip sound at half volume to give audible
// feedback that distinguishes a recycle from a normal draw. // feedback that distinguishes a recycle from a normal draw.
let stock_len = game let stock_len = game.as_ref().map_or(1, |g| g.0.stock_cards().len()); // default > 0 → normal draw sound
.as_ref()
.map_or(1, |g| g.0.stock_cards().len()); // default > 0 → normal draw sound
if is_recycle(stock_len) { if is_recycle(stock_len) {
let mut data = lib.flip.clone(); let mut data = lib.flip.clone();
+3 -6
View File
@@ -16,10 +16,9 @@ use bevy::color::Color;
use bevy::prelude::*; use bevy::prelude::*;
use bevy::sprite::Anchor; use bevy::sprite::Anchor;
use bevy::window::WindowResized; use bevy::window::WindowResized;
use klondike::{Foundation, KlondikePile, Tableau};
use solitaire_core::card::{Card, Rank, Suit}; use solitaire_core::card::{Card, Rank, Suit};
use solitaire_core::game_state::{DrawMode, GameState}; use solitaire_core::game_state::{DrawMode, GameState};
use klondike::{Foundation, KlondikePile, Tableau};
use crate::animation_plugin::{CARD_ANIM_Z_LIFT, CardAnim, EffectiveSlideDuration}; use crate::animation_plugin::{CARD_ANIM_Z_LIFT, CardAnim, EffectiveSlideDuration};
use crate::card_animation::CardAnimation; use crate::card_animation::CardAnimation;
@@ -2575,8 +2574,7 @@ mod tests {
"need at least 3 waste cards for this test" "need at least 3 waste cards for this test"
); );
let waste_ids: std::collections::HashSet<u32> = let waste_ids: std::collections::HashSet<u32> = waste_pile.iter().map(|c| c.id).collect();
waste_pile.iter().map(|c| c.id).collect();
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout); let positions = card_positions(&g, &layout);
@@ -2628,8 +2626,7 @@ mod tests {
let count = waste_pile.len(); let count = waste_pile.len();
assert!(count >= 2, "need at least 2 waste cards"); assert!(count >= 2, "need at least 2 waste cards");
let waste_ids: std::collections::HashSet<u32> = let waste_ids: std::collections::HashSet<u32> = waste_pile.iter().map(|c| c.id).collect();
waste_pile.iter().map(|c| c.id).collect();
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true); let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
let positions = card_positions(&g, &layout); let positions = card_positions(&g, &layout);
+6 -3
View File
@@ -331,7 +331,11 @@ fn update_drop_target_overlays(
/// for everything else it is card-sized. Replicated here rather than /// for everything else it is card-sized. Replicated here rather than
/// imported because `pile_drop_rect` is private to `input_plugin` and /// imported because `pile_drop_rect` is private to `input_plugin` and
/// this overlay is the only other consumer. /// this overlay is the only other consumer.
fn drop_overlay_rect(pile: &KlondikePile, layout: &Layout, game: &GameState) -> Option<(Vec2, Vec2)> { fn drop_overlay_rect(
pile: &KlondikePile,
layout: &Layout,
game: &GameState,
) -> Option<(Vec2, Vec2)> {
let centre = layout.pile_positions.get(pile).copied()?; let centre = layout.pile_positions.get(pile).copied()?;
if matches!(pile, KlondikePile::Tableau(_)) { if matches!(pile, KlondikePile::Tableau(_)) {
let card_count = game.pile(*pile).len(); let card_count = game.pile(*pile).len();
@@ -658,5 +662,4 @@ mod tests {
"Tableau(2) must not be highlighted for an illegal drop, got {overlays:?}" "Tableau(2) must not be highlighted for an illegal drop, got {overlays:?}"
); );
} }
}
}
@@ -13,10 +13,10 @@
use bevy::input::ButtonInput; use bevy::input::ButtonInput;
use bevy::prelude::*; use bevy::prelude::*;
use chrono::{DateTime, Duration, Local, NaiveDate, Utc};
use solitaire_data::{daily_seed_for, save_progress_to};
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future}; use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use chrono::{DateTime, Duration, Local, NaiveDate, Utc};
use solitaire_data::{daily_seed_for, save_progress_to};
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
use solitaire_sync::ChallengeGoal; use solitaire_sync::ChallengeGoal;
@@ -354,7 +354,6 @@ fn check_date_rollover(
} }
} }
#[cfg(test)] #[cfg(test)]
#[allow(dead_code)] #[allow(dead_code)]
mod tests { mod tests {
+1 -1
View File
@@ -849,8 +849,8 @@ mod tests {
#[test] #[test]
fn shake_anim_skipped_under_reduce_motion() { fn shake_anim_skipped_under_reduce_motion() {
use bevy::ecs::message::Messages; use bevy::ecs::message::Messages;
use solitaire_core::game_state::{DrawMode, GameState};
use klondike::Tableau; use klondike::Tableau;
use solitaire_core::game_state::{DrawMode, GameState};
use solitaire_data::Settings; use solitaire_data::Settings;
let mut app = App::new(); let mut app = App::new();
+46 -28
View File
@@ -13,8 +13,8 @@ use chrono::Utc;
use bevy::prelude::*; use bevy::prelude::*;
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future}; use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use bevy::window::AppLifecycle; use bevy::window::AppLifecycle;
use solitaire_core::game_state::{DrawMode, GameMode, GameState};
use klondike::KlondikePile; use klondike::KlondikePile;
use solitaire_core::game_state::{DrawMode, GameMode, GameState};
use solitaire_core::solver::{SolverConfig, SolverResult, try_solve}; use solitaire_core::solver::{SolverConfig, SolverResult, try_solve};
#[allow(deprecated)] #[allow(deprecated)]
use solitaire_data::latest_replay_path; use solitaire_data::latest_replay_path;
@@ -521,10 +521,7 @@ fn handle_new_game(
// hides that information and reads naturally as "dealt from the // hides that information and reads naturally as "dealt from the
// deck." Skipped when LayoutResource isn't present (headless tests). // deck." Skipped when LayoutResource isn't present (headless tests).
if let Some(layout) = layout.as_ref() if let Some(layout) = layout.as_ref()
&& let Some(stock) = layout && let Some(stock) = layout.0.pile_positions.get(&klondike::KlondikePile::Stock)
.0
.pile_positions
.get(&klondike::KlondikePile::Stock)
{ {
for mut tx in &mut card_transforms { for mut tx in &mut card_transforms {
tx.translation.x = stock.x; tx.translation.x = stock.x;
@@ -1047,17 +1044,11 @@ fn foundation_slot(foundation: klondike::Foundation) -> Option<u8> {
/// previous heuristic incorrectly did (Quat hit this with 4 cards /// previous heuristic incorrectly did (Quat hit this with 4 cards
/// remaining and the game just sat there). /// remaining and the game just sat there).
pub fn has_legal_moves(game: &GameState) -> bool { pub fn has_legal_moves(game: &GameState) -> bool {
// Drawing from a non-empty stock, and recycling a non-empty waste back to // Drawing from a non-empty stock, and recycling a non-empty waste back to
// stock, are always legal moves in standard Klondike (unlimited recycles). // stock, are always legal moves in standard Klondike (unlimited recycles).
// A game can only be genuinely stuck when both stock AND waste are exhausted. // A game can only be genuinely stuck when both stock AND waste are exhausted.
let stock_empty = game let stock_empty = game.stock_cards().is_empty();
.stock_cards() let waste_empty = game.waste_cards().is_empty();
.is_empty();
let waste_empty = game
.waste_cards()
.is_empty();
if !stock_empty || !waste_empty { if !stock_empty || !waste_empty {
return true; return true;
} }
@@ -1191,7 +1182,10 @@ fn handle_game_over_input(
if keys.just_pressed(KeyCode::KeyN) || keys.just_pressed(KeyCode::Escape) { if keys.just_pressed(KeyCode::KeyN) || keys.just_pressed(KeyCode::Escape) {
// confirmed: true — the game is already stuck; no abandon-confirmation needed. // confirmed: true — the game is already stuck; no abandon-confirmation needed.
new_game.write(NewGameRequestEvent { confirmed: true, ..default() }); new_game.write(NewGameRequestEvent {
confirmed: true,
..default()
});
} else if keys.just_pressed(KeyCode::KeyU) { } else if keys.just_pressed(KeyCode::KeyU) {
for entity in &screens { for entity in &screens {
commands.entity(entity).despawn(); commands.entity(entity).despawn();
@@ -1219,7 +1213,10 @@ fn handle_game_over_button_input(
} }
if new_game_buttons.iter().any(|i| *i == Interaction::Pressed) { if new_game_buttons.iter().any(|i| *i == Interaction::Pressed) {
// confirmed: true — the game is already stuck; no abandon-confirmation needed. // confirmed: true — the game is already stuck; no abandon-confirmation needed.
new_game.write(NewGameRequestEvent { confirmed: true, ..default() }); new_game.write(NewGameRequestEvent {
confirmed: true,
..default()
});
} else if undo_buttons.iter().any(|i| *i == Interaction::Pressed) { } else if undo_buttons.iter().any(|i| *i == Interaction::Pressed) {
for entity in &screens { for entity in &screens {
commands.entity(entity).despawn(); commands.entity(entity).despawn();
@@ -1388,9 +1385,11 @@ mod tests {
#[test] #[test]
fn new_game_request_reseeds() { fn new_game_request_reseeds() {
let mut app = test_app(1); let mut app = test_app(1);
let before: Vec<u32> = app.world().resource::<GameStateResource>().0.pile(KlondikePile::Tableau( let before: Vec<u32> = app
Tableau::Tableau1, .world()
)) .resource::<GameStateResource>()
.0
.pile(KlondikePile::Tableau(Tableau::Tableau1))
.iter() .iter()
.map(|c| c.id) .map(|c| c.id)
.collect(); .collect();
@@ -1402,9 +1401,11 @@ mod tests {
}); });
app.update(); app.update();
let after: Vec<u32> = app.world().resource::<GameStateResource>().0.pile(KlondikePile::Tableau( let after: Vec<u32> = app
Tableau::Tableau1, .world()
)) .resource::<GameStateResource>()
.0
.pile(KlondikePile::Tableau(Tableau::Tableau1))
.iter() .iter()
.map(|c| c.id) .map(|c| c.id)
.collect(); .collect();
@@ -1415,17 +1416,25 @@ mod tests {
fn settings_changed_updates_take_from_foundation_flag() { fn settings_changed_updates_take_from_foundation_flag() {
let mut app = test_app(1); let mut app = test_app(1);
assert!( assert!(
app.world().resource::<GameStateResource>().0.take_from_foundation, app.world()
.resource::<GameStateResource>()
.0
.take_from_foundation,
"fresh game should inherit default take_from_foundation=true", "fresh game should inherit default take_from_foundation=true",
); );
let mut settings = solitaire_data::Settings::default(); let mut settings = solitaire_data::Settings::default();
settings.take_from_foundation = false; settings.take_from_foundation = false;
app.world_mut() app.world_mut()
.write_message(crate::settings_plugin::SettingsChangedEvent(settings.clone())); .write_message(crate::settings_plugin::SettingsChangedEvent(
settings.clone(),
));
app.update(); app.update();
assert!( assert!(
!app.world().resource::<GameStateResource>().0.take_from_foundation, !app.world()
.resource::<GameStateResource>()
.0
.take_from_foundation,
"settings event must forward take_from_foundation=false into live game state", "settings event must forward take_from_foundation=false into live game state",
); );
@@ -1434,7 +1443,10 @@ mod tests {
.write_message(crate::settings_plugin::SettingsChangedEvent(settings)); .write_message(crate::settings_plugin::SettingsChangedEvent(settings));
app.update(); app.update();
assert!( assert!(
app.world().resource::<GameStateResource>().0.take_from_foundation, app.world()
.resource::<GameStateResource>()
.0
.take_from_foundation,
"settings event must forward take_from_foundation=true into live game state", "settings event must forward take_from_foundation=true into live game state",
); );
} }
@@ -1634,7 +1646,9 @@ mod tests {
// Build a tableau with two face-up cards. // Build a tableau with two face-up cards.
{ {
let mut gs = app.world_mut().resource_mut::<GameStateResource>(); let mut gs = app.world_mut().resource_mut::<GameStateResource>();
gs.0.set_test_tableau_cards(Tableau::Tableau1, vec![ gs.0.set_test_tableau_cards(
Tableau::Tableau1,
vec![
Card { Card {
id: 910, id: 910,
suit: Suit::Clubs, suit: Suit::Clubs,
@@ -1647,7 +1661,8 @@ mod tests {
rank: Rank::Queen, rank: Rank::Queen,
face_up: true, face_up: true,
}, },
]); ],
);
gs.0.set_test_tableau_cards( gs.0.set_test_tableau_cards(
Tableau::Tableau2, Tableau::Tableau2,
vec![Card { vec![Card {
@@ -2359,7 +2374,10 @@ mod tests {
Tableau::Tableau7, Tableau::Tableau7,
] { ] {
assert_eq!( assert_eq!(
app.world().resource::<GameStateResource>().0.pile(KlondikePile::Tableau(tableau)), app.world()
.resource::<GameStateResource>()
.0
.pile(KlondikePile::Tableau(tableau)),
expected.pile(KlondikePile::Tableau(tableau)), expected.pile(KlondikePile::Tableau(tableau)),
"tableau column {tableau:?} must match the unfiltered seed", "tableau column {tableau:?} must match the unfiltered seed",
); );
+4 -1
View File
@@ -2404,7 +2404,10 @@ fn update_selection_hud(
/// When the slot has a claimed suit (any card has landed) the announcement is /// When the slot has a claimed suit (any card has landed) the announcement is
/// "▶ {Suit} Foundation"; while the slot is empty it falls back to a /// "▶ {Suit} Foundation"; while the slot is empty it falls back to a
/// "▶ Foundation N" placeholder labelled by the 1-based slot index. /// "▶ Foundation N" placeholder labelled by the 1-based slot index.
fn foundation_selection_label(slot: Foundation, game: &solitaire_core::game_state::GameState) -> String { fn foundation_selection_label(
slot: Foundation,
game: &solitaire_core::game_state::GameState,
) -> String {
let claimed = game let claimed = game
.pile(KlondikePile::Foundation(slot)) .pile(KlondikePile::Foundation(slot))
.first() .first()
+12 -3
View File
@@ -269,7 +269,10 @@ pub fn compute_layout(
5 => Tableau::Tableau6, 5 => Tableau::Tableau6,
_ => Tableau::Tableau7, _ => Tableau::Tableau7,
}; };
pile_positions.insert(KlondikePile::Tableau(tableau), Vec2::new(col_x(i), tableau_y)); pile_positions.insert(
KlondikePile::Tableau(tableau),
Vec2::new(col_x(i), tableau_y),
);
} }
// Adaptive tableau fan fraction. On height-limited (desktop) windows the // Adaptive tableau fan fraction. On height-limited (desktop) windows the
@@ -339,7 +342,9 @@ mod tests {
Tableau::Tableau7, Tableau::Tableau7,
] { ] {
assert!( assert!(
layout.pile_positions.contains_key(&KlondikePile::Tableau(tableau)), layout
.pile_positions
.contains_key(&KlondikePile::Tableau(tableau)),
"missing tableau {tableau:?}" "missing tableau {tableau:?}"
); );
} }
@@ -758,7 +763,11 @@ mod tests {
let window = Vec2::new(360.0, 800.0); let window = Vec2::new(360.0, 800.0);
let without = compute_layout(window, 0.0, 0.0, true); let without = compute_layout(window, 0.0, 0.0, true);
let with_inset = compute_layout(window, 0.0, 48.0, true); let with_inset = compute_layout(window, 0.0, 48.0, true);
for pile in [KlondikePile::Stock, KlondikePile::Tableau(Tableau::Tableau1), KlondikePile::Tableau(Tableau::Tableau7)] { for pile in [
KlondikePile::Stock,
KlondikePile::Tableau(Tableau::Tableau1),
KlondikePile::Tableau(Tableau::Tableau7),
] {
assert!( assert!(
(without.pile_positions[&pile].x - with_inset.pile_positions[&pile].x).abs() < 1e-3, (without.pile_positions[&pile].x - with_inset.pile_positions[&pile].x).abs() < 1e-3,
"{pile:?} x-position must not change with safe_area_bottom", "{pile:?} x-position must not change with safe_area_bottom",
+1 -1
View File
@@ -153,7 +153,6 @@ pub use safe_area::{SafeAreaAnchoredTop, SafeAreaInsets, SafeAreaInsetsPlugin};
pub use selection_plugin::{ pub use selection_plugin::{
KeyboardDragState, SelectionHighlight, SelectionPlugin, SelectionState, KeyboardDragState, SelectionHighlight, SelectionPlugin, SelectionState,
}; };
pub use touch_selection_plugin::{TouchSelectionPlugin, TouchSelectionState};
pub use settings_plugin::{ pub use settings_plugin::{
PendingWindowGeometry, SFX_STEP, SettingsChangedEvent, SettingsPlugin, SettingsResource, PendingWindowGeometry, SFX_STEP, SettingsChangedEvent, SettingsPlugin, SettingsResource,
SettingsScreen, WINDOW_GEOMETRY_DEBOUNCE_SECS, SettingsScreen, WINDOW_GEOMETRY_DEBOUNCE_SECS,
@@ -179,6 +178,7 @@ pub use theme::{
pub use time_attack_plugin::{ pub use time_attack_plugin::{
TIME_ATTACK_DURATION_SECS, TimeAttackEndedEvent, TimeAttackPlugin, TimeAttackResource, TIME_ATTACK_DURATION_SECS, TimeAttackEndedEvent, TimeAttackPlugin, TimeAttackResource,
}; };
pub use touch_selection_plugin::{TouchSelectionPlugin, TouchSelectionState};
pub use ui_focus::{Disabled, FocusGroup, Focusable, FocusedButton, UiFocusPlugin}; pub use ui_focus::{Disabled, FocusGroup, Focusable, FocusedButton, UiFocusPlugin};
pub use ui_modal::{ pub use ui_modal::{
ButtonVariant, ModalActions, ModalBody, ModalButton, ModalCard, ModalHeader, ModalScrim, ButtonVariant, ModalActions, ModalBody, ModalButton, ModalCard, ModalHeader, ModalScrim,
+5 -2
View File
@@ -26,8 +26,8 @@
use bevy::prelude::*; use bevy::prelude::*;
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future}; use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use solitaire_core::game_state::GameState;
use klondike::KlondikePile; use klondike::KlondikePile;
use solitaire_core::game_state::GameState;
use solitaire_core::solver::{SolverConfig, SolverResult, try_solve_from_state}; use solitaire_core::solver::{SolverConfig, SolverResult, try_solve_from_state};
use crate::card_plugin::CardEntity; use crate::card_plugin::CardEntity;
@@ -101,7 +101,10 @@ struct HintTask {
enum HintTaskOutput { enum HintTaskOutput {
/// Solver verdict was `Winnable`; here is the first move on the /// Solver verdict was `Winnable`; here is the first move on the
/// solution path. /// solution path.
SolverMove { from: KlondikePile, to: KlondikePile }, SolverMove {
from: KlondikePile,
to: KlondikePile,
},
/// Solver was `Unwinnable` or `Inconclusive`. The poll system /// Solver was `Unwinnable` or `Inconclusive`. The poll system
/// runs the legacy heuristic against the live `GameState` so the /// runs the legacy heuristic against the live `GameState` so the
/// H key always produces feedback while any legal move exists. /// H key always produces feedback while any legal move exists.
@@ -97,7 +97,11 @@ pub(crate) fn format_move_body(m: &ReplayMove) -> String {
match m { match m {
ReplayMove::StockClick => "stock cycle".to_string(), ReplayMove::StockClick => "stock cycle".to_string(),
ReplayMove::Move { from, to, .. } => { ReplayMove::Move { from, to, .. } => {
format!("{} \u{2192} {}", format_saved_pile(from), format_saved_pile(to)) format!(
"{} \u{2192} {}",
format_saved_pile(from),
format_saved_pile(to)
)
} }
} }
} }
+2 -4
View File
@@ -25,15 +25,14 @@
mod format; mod format;
mod input; mod input;
mod update;
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
mod update;
pub(crate) use self::format::*; pub(crate) use self::format::*;
pub(crate) use self::input::*; pub(crate) use self::input::*;
pub(crate) use self::update::*; pub(crate) use self::update::*;
use bevy::prelude::*;
use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent, UndoRequestEvent}; use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent, UndoRequestEvent};
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
use crate::platform::SHOW_KEYBOARD_ACCELERATORS; use crate::platform::SHOW_KEYBOARD_ACCELERATORS;
@@ -44,6 +43,7 @@ use crate::ui_theme::{
STATE_SUCCESS, STATE_SUCCESS_HC, TEXT_PRIMARY, TEXT_PRIMARY_HC, TEXT_SECONDARY, TYPE_BODY, STATE_SUCCESS, STATE_SUCCESS_HC, TEXT_PRIMARY, TEXT_PRIMARY_HC, TEXT_SECONDARY, TYPE_BODY,
TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_4, Z_DROP_OVERLAY, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_4, Z_DROP_OVERLAY,
}; };
use bevy::prelude::*;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Z-index — see `ui_theme::Z_MODAL_SCRIM` (200) for the next layer above. // Z-index — see `ui_theme::Z_MODAL_SCRIM` (200) for the next layer above.
@@ -316,7 +316,6 @@ pub struct ReplayOverlayScrubNotch;
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct ReplayOverlayScrubNotchLabel; pub struct ReplayOverlayScrubNotchLabel;
/// Marker on the keybind-hint footer row at the bottom edge of the /// Marker on the keybind-hint footer row at the bottom edge of the
/// banner. Carries two `Text` children: a vim-style mode indicator /// banner. Carries two `Text` children: a vim-style mode indicator
/// (`▌ NORMAL │ replay`) on the left and the keybind hint /// (`▌ NORMAL │ replay`) on the left and the keybind hint
@@ -1270,4 +1269,3 @@ fn win_move_marker_pct(state: &ReplayPlaybackState) -> Option<f32> {
let frac = (idx as f32 / total as f32).clamp(0.0, 1.0); let frac = (idx as f32 / total as f32).clamp(0.0, 1.0);
Some(frac * 100.0) Some(frac * 100.0)
} }
+17 -6
View File
@@ -854,8 +854,7 @@ fn scrub_notch_labels_carry_helper_strings() {
let mut texts = scrub_notch_label_texts(&mut app); let mut texts = scrub_notch_label_texts(&mut app);
texts.sort(); texts.sort();
let mut expected: Vec<String> = let mut expected: Vec<String> = scrub_notch_labels().iter().map(|s| s.to_string()).collect();
scrub_notch_labels().iter().map(|s| s.to_string()).collect();
expected.sort(); expected.sort();
assert_eq!( assert_eq!(
texts, expected, texts, expected,
@@ -1106,10 +1105,22 @@ fn move_log_active_row_text(app: &mut App) -> String {
#[test] #[test]
fn format_pile_uses_one_indexed_lowercase_names() { fn format_pile_uses_one_indexed_lowercase_names() {
assert_eq!(format_pile(&KlondikePile::Stock), "waste"); assert_eq!(format_pile(&KlondikePile::Stock), "waste");
assert_eq!(format_pile(&KlondikePile::Foundation(Foundation::Foundation1)), "foundation 1"); assert_eq!(
assert_eq!(format_pile(&KlondikePile::Foundation(Foundation::Foundation3)), "foundation 3"); format_pile(&KlondikePile::Foundation(Foundation::Foundation1)),
assert_eq!(format_pile(&KlondikePile::Tableau(Tableau::Tableau1)), "tableau 1"); "foundation 1"
assert_eq!(format_pile(&KlondikePile::Tableau(Tableau::Tableau7)), "tableau 7"); );
assert_eq!(
format_pile(&KlondikePile::Foundation(Foundation::Foundation3)),
"foundation 3"
);
assert_eq!(
format_pile(&KlondikePile::Tableau(Tableau::Tableau1)),
"tableau 1"
);
assert_eq!(
format_pile(&KlondikePile::Tableau(Tableau::Tableau7)),
"tableau 7"
);
} }
/// Move-body formatter renders `StockClick` as a label and /// Move-body formatter renders `StockClick` as a label and
@@ -1,10 +1,10 @@
use bevy::prelude::*; use bevy::prelude::*;
use super::*;
use super::format::{ use super::format::{
format_active_move_row, format_foundations_row, format_kth_next_row, format_active_move_row, format_foundations_row, format_kth_next_row, format_kth_recent_row,
format_kth_recent_row, format_move_log_header, format_progress, format_stock_waste_row, format_move_log_header, format_progress, format_stock_waste_row,
}; };
use super::*;
use crate::layout::LayoutResource; use crate::layout::LayoutResource;
use crate::replay_playback::ReplayPlaybackState; use crate::replay_playback::ReplayPlaybackState;
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
+9 -9
View File
@@ -268,11 +268,12 @@ pub fn step_replay_playback(
} }
match &replay.moves[*cursor] { match &replay.moves[*cursor] {
ReplayMove::Move { from, to, count } => { ReplayMove::Move { from, to, count } => {
let (Ok(from), Ok(to)) = ( let (Ok(from), Ok(to)) = (KlondikePile::try_from(*from), KlondikePile::try_from(*to))
KlondikePile::try_from(*from), else {
KlondikePile::try_from(*to), warn!(
) else { "skipping replay move with invalid pile encoding at cursor {}",
warn!("skipping replay move with invalid pile encoding at cursor {}", *cursor); *cursor
);
*cursor += 1; *cursor += 1;
return false; return false;
}; };
@@ -379,10 +380,9 @@ fn tick_replay_playback(
while *secs_to_next <= 0.0 && *cursor < replay.moves.len() { while *secs_to_next <= 0.0 && *cursor < replay.moves.len() {
match &replay.moves[*cursor] { match &replay.moves[*cursor] {
ReplayMove::Move { from, to, count } => { ReplayMove::Move { from, to, count } => {
if let (Ok(from), Ok(to)) = ( if let (Ok(from), Ok(to)) =
KlondikePile::try_from(*from), (KlondikePile::try_from(*from), KlondikePile::try_from(*to))
KlondikePile::try_from(*to), {
) {
moves_writer.write(MoveRequestEvent { moves_writer.write(MoveRequestEvent {
from, from,
to, to,
+1 -1
View File
@@ -6,8 +6,8 @@ use std::sync::Arc;
use bevy::math::Vec2; use bevy::math::Vec2;
use bevy::prelude::Resource; use bevy::prelude::Resource;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use solitaire_core::game_state::GameState;
use klondike::KlondikePile; use klondike::KlondikePile;
use solitaire_core::game_state::GameState;
/// Wraps the currently active `GameState`. Single source of truth for the in-progress game. /// Wraps the currently active `GameState`. Single source of truth for the in-progress game.
#[derive(Resource, Debug, Clone)] #[derive(Resource, Debug, Clone)]
+15 -10
View File
@@ -202,7 +202,10 @@ fn cycled_piles() -> Vec<KlondikePile> {
/// ///
/// If `current` is `None` the first available pile is returned. /// If `current` is `None` the first available pile is returned.
/// If `available` is empty, `None` is returned. /// If `available` is empty, `None` is returned.
pub fn cycle_next_pile(available: &[KlondikePile], current: Option<&KlondikePile>) -> Option<KlondikePile> { pub fn cycle_next_pile(
available: &[KlondikePile],
current: Option<&KlondikePile>,
) -> Option<KlondikePile> {
if available.is_empty() { if available.is_empty() {
return None; return None;
} }
@@ -235,7 +238,11 @@ pub fn cycle_next_pile(available: &[KlondikePile], current: Option<&KlondikePile
/// ///
/// Both `current` and `next` must be `Some`; if either is `None` this returns /// Both `current` and `next` must be `Some`; if either is `None` this returns
/// `false`. /// `false`.
fn did_wrap(available: &[KlondikePile], current: Option<&KlondikePile>, next: Option<&KlondikePile>) -> bool { fn did_wrap(
available: &[KlondikePile],
current: Option<&KlondikePile>,
next: Option<&KlondikePile>,
) -> bool {
let (Some(cur), Some(nxt)) = (current, next) else { let (Some(cur), Some(nxt)) = (current, next) else {
return false; return false;
}; };
@@ -386,9 +393,7 @@ fn handle_selection_keys(
KlondikePile::Tableau(Tableau::Tableau7), KlondikePile::Tableau(Tableau::Tableau7),
]; ];
all.into_iter() all.into_iter()
.filter(|p| { .filter(|p| pile_cards(&game.0, p).last().is_some_and(|c| c.face_up))
pile_cards(&game.0, p).last().is_some_and(|c| c.face_up)
})
.collect() .collect()
}; };
@@ -717,10 +722,7 @@ fn update_selection_highlight(
/// Returns the top face-up card on `pile`, or `None` if the pile is /// Returns the top face-up card on `pile`, or `None` if the pile is
/// empty or its top card is face-down. /// empty or its top card is face-down.
fn top_face_up_card( fn top_face_up_card(pile: &KlondikePile, game: &GameState) -> Option<Card> {
pile: &KlondikePile,
game: &GameState,
) -> Option<Card> {
pile_cards(game, pile).last().filter(|c| c.face_up).cloned() pile_cards(game, pile).last().filter(|c| c.face_up).cloned()
} }
@@ -1162,7 +1164,10 @@ mod tests {
// DragState must mirror the lifted cards and carry the keyboard sentinel. // DragState must mirror the lifted cards and carry the keyboard sentinel.
let drag = app.world().resource::<DragState>(); let drag = app.world().resource::<DragState>();
assert_eq!(drag.cards, vec![100]); assert_eq!(drag.cards, vec![100]);
assert_eq!(drag.origin_pile, Some(KlondikePile::Tableau(Tableau::Tableau1))); assert_eq!(
drag.origin_pile,
Some(KlondikePile::Tableau(Tableau::Tableau1))
);
assert_eq!(drag.active_touch_id, Some(KEYBOARD_DRAG_TOUCH_ID)); assert_eq!(drag.active_touch_id, Some(KEYBOARD_DRAG_TOUCH_ID));
} }
+1 -1
View File
@@ -33,9 +33,9 @@ use crate::events::{
use crate::font_plugin::FontResource; use crate::font_plugin::FontResource;
use crate::progress_plugin::ProgressResource; use crate::progress_plugin::ProgressResource;
use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource}; use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource};
use crate::theme::{ThemeThumbnailCache, ThemeThumbnailPair};
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
use crate::theme::{ImportError, import_theme, refresh_registry}; use crate::theme::{ImportError, import_theme, refresh_registry};
use crate::theme::{ThemeThumbnailCache, ThemeThumbnailPair};
use crate::ui_focus::{FocusGroup, FocusRow, Focusable, FocusedButton}; use crate::ui_focus::{FocusGroup, FocusRow, Focusable, FocusedButton};
use crate::ui_modal::{ use crate::ui_modal::{
ButtonVariant, ModalButton, ModalScrim, spawn_modal, spawn_modal_actions, spawn_modal_button, ButtonVariant, ModalButton, ModalScrim, spawn_modal, spawn_modal_actions, spawn_modal_button,
+1 -1
View File
@@ -22,9 +22,9 @@
use std::path::Path; use std::path::Path;
use bevy::log::warn; use bevy::log::warn;
use bevy::prelude::{App, Plugin, Resource};
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
use bevy::prelude::Startup; use bevy::prelude::Startup;
use bevy::prelude::{App, Plugin, Resource};
use serde::Deserialize; use serde::Deserialize;
use super::ThemeMeta; use super::ThemeMeta;
+3 -3
View File
@@ -249,11 +249,11 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
const CSP: &str = concat!( const CSP: &str = concat!(
"default-src 'self'; ", "default-src 'self'; ",
"script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; ", "script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://analytics.aleshym.co; ",
"style-src 'self' 'unsafe-inline'; ", "style-src 'self' 'unsafe-inline'; ",
"font-src 'self'; ", "font-src 'self'; ",
"img-src 'self' data:; ", "img-src 'self' data: https://analytics.aleshym.co; ",
"connect-src 'self'; ", "connect-src 'self' https://analytics.aleshym.co; ",
"object-src 'none'; ", "object-src 'none'; ",
"frame-ancestors 'none'", "frame-ancestors 'none'",
); );
+1 -4
View File
@@ -196,10 +196,7 @@ async fn update_leaderboard_if_opted_in(
user_id: &str, user_id: &str,
payload: &SyncPayload, payload: &SyncPayload,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
let opted_in = sqlx::query!( let opted_in = sqlx::query!("SELECT leaderboard_opt_in FROM users WHERE id = ?", user_id)
"SELECT leaderboard_opt_in FROM users WHERE id = ?",
user_id
)
.fetch_optional(pool) .fetch_optional(pool)
.await? .await?
.map(|r| r.leaderboard_opt_in) .map(|r| r.leaderboard_opt_in)