Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aebb401c44 | |||
| a550a0cdf9 | |||
| 8f5193035b | |||
| c21c0ebf99 | |||
| ccccdd2b40 | |||
| f1d96012f1 | |||
| 7eb1181e50 | |||
| f444378184 | |||
| 927598202e |
@@ -0,0 +1,167 @@
|
||||
# Integrating `card_game` / `klondike` as the Solitaire Core
|
||||
|
||||
**Context:** A collaborator ([Quaternions](https://git.aleshym.co/Quaternions/card_game)) is building a pure-logic Klondike library in Rust. This document maps what that library currently provides against what Ferrous Solitaire's `solitaire_core` crate requires.
|
||||
|
||||
**Approach:** Most gaps are closed in Ferrous Solitaire's own `solitaire_core` crate via a wrapper/adapter layer. Gaps 1, 3, and 4 have been addressed upstream. Integration is ready to begin.
|
||||
|
||||
---
|
||||
|
||||
## What `card_game` + `klondike` Already Has
|
||||
|
||||
### `card_game` crate (generic primitives) — v0.4.0
|
||||
| Feature | Notes |
|
||||
|---|---|
|
||||
| `Card` (Deck + Suit + Rank packed in 1 byte) | `NonZeroU8` layout — no heap allocation |
|
||||
| `Suit`, `Rank`, `Deck` enums | Full A→K, 4 suits, up to 4 deck IDs |
|
||||
| `Stack<CAP>` | Const-generic `ArrayVec` wrapper |
|
||||
| `Pile<DN, UP>` | Face-down + face-up stacks; `flip_up`, `pop_flip_up` |
|
||||
| `Game` trait | `possible_instructions`, `is_instruction_valid`, `process_instruction`, `is_win` |
|
||||
| `Session` | Wraps a `Game`; snapshot-based undo (O(1)), score including undo penalty |
|
||||
| `Session::solve()` | Built-in DFS solver with move/state budgets; returns `Solution<G>` or `SolveError` |
|
||||
| `StateSnapshot<G>` | Pre-move state + instruction; used by snapshot history and `Solution` |
|
||||
| `SessionState::score()` | = `game_score + undos × undo_penalty` (−15 by default via `SessionConfig`) |
|
||||
| `SessionConfig` | `undo_penalty`, `solve_moves_budget`, `solve_states_budget` |
|
||||
|
||||
### `klondike` crate (Klondike rules) — v0.3.0
|
||||
| Feature | Notes |
|
||||
|---|---|
|
||||
| 7 tableau + 4 foundation + 1 stock | Fully dealt from a seeded RNG |
|
||||
| Draw-1 / Draw-3 config | `KlondikeConfig::draw_stock` (`DrawStockConfig`) |
|
||||
| `MoveFromFoundationConfig` | `Allowed` (upstream default) / `Disallowed`; controls foundation → tableau rule |
|
||||
| `ScoringConfig` | Configurable deltas: `move_to_foundation` (+10), `flip_up_bonus` (+5), `move_to_tableau` (+5), `move_from_foundation` (−15), `recycle` (0 by default) |
|
||||
| `KlondikeStats::score(&config)` | Computes score from per-event counters × `ScoringConfig` deltas |
|
||||
| `KlondikeStats` counters | `move_to_foundation_count`, `flip_up_bonus_count`, `move_to_tableau_count`, `move_from_foundation_count`, `recycle_count`, `moves` |
|
||||
| Foundation placement (Ace start, suit-matched A→K) | ✅ |
|
||||
| Tableau placement (alternating colour, K on empty) | ✅ |
|
||||
| Multi-card stack moves (via `SkipCards`) | ✅ |
|
||||
| `RotateStock` (recycle waste → stock) | ✅ |
|
||||
| `is_win_trivial` (all face-down cards cleared) | Auto-complete trigger |
|
||||
| `get_auto_move` / `get_sorted_moves` | Priority-ranked move suggestion (take `&KlondikeConfig`) |
|
||||
| Benchmark suite (`klondike-bench`) | 1 000-game throughput test |
|
||||
| CLI display (`klondike-cli`) | Terminal renderer |
|
||||
|
||||
---
|
||||
|
||||
## What Ferrous Solitaire's `solitaire_core` Needs (Gaps)
|
||||
|
||||
### 1. Scoring — remaining adapter responsibilities
|
||||
Ferrous uses **Windows XP Standard** scoring. The exact table already implemented in `solitaire_core/src/scoring.rs`:
|
||||
|
||||
| Event | Delta | Handled by |
|
||||
|---|---|---|
|
||||
| Any card → foundation | +10 | `KlondikeStats` / `ScoringConfig::move_to_foundation` ✅ |
|
||||
| Waste → tableau | +5 | `KlondikeStats` / `ScoringConfig::move_to_tableau` ✅ |
|
||||
| Flip face-down tableau card | +5 | `KlondikeStats` / `ScoringConfig::flip_up_bonus` ✅ |
|
||||
| Foundation → tableau | −15 | `KlondikeStats` / `ScoringConfig::move_from_foundation` ✅ |
|
||||
| Undo | −15 | `SessionStats` / `SessionConfig::undo_penalty` ✅ |
|
||||
| Recycle (Draw-1, after 1st free) | −100 | **Our adapter** — see below |
|
||||
| Recycle (Draw-3, after 3rd free) | −20 | **Our adapter** — see below |
|
||||
| Score floor | `score.max(0)` always | **Our adapter** |
|
||||
| Time bonus on win | `700_000 / elapsed_seconds` | **Our adapter** (not wasm-portable) |
|
||||
|
||||
Reference: <https://www.solitaireparadise.com/games_list/klondike_solitaire_scoring.html>
|
||||
|
||||
**Undo penalty:** `SessionState::score()` = `KlondikeStats.score(&scoring) + undos × undo_penalty`. The −15 undo penalty is built into `SessionConfig` (default). Once `GameState` fully delegates to `Session`, our `KlondikeAdapter::score_for_undo()` helper becomes redundant.
|
||||
|
||||
**Recycle penalty note:** `ScoringConfig::recycle` is a flat delta (default 0 = always free). WXP allows a fixed number of free recycles before charging a penalty, which the upstream library cannot express with a single delta. Our adapter tracks `recycle_count` from `KlondikeStats` and applies the penalty only beyond the free allowance.
|
||||
|
||||
**In our wrapper:** Configure `ScoringConfig` with the WXP deltas for the five events upstream handles (including undo via `SessionConfig`). Implement recycle-with-free-allowance, score floor, and time bonus in the adapter.
|
||||
|
||||
### 2. Game Modes
|
||||
Ferrous has three modes that alter scoring and undo behaviour:
|
||||
|
||||
| Mode | Scoring | Undo |
|
||||
|---|---|---|
|
||||
| **Classic** | Full WXP scoring (table above) | Allowed (−15 penalty) |
|
||||
| **Zen** | All deltas suppressed — score stays 0 | Allowed (no penalty) |
|
||||
| **Challenge** | Full WXP scoring | **Disabled** — returns an error |
|
||||
|
||||
Zen is intended for relaxed play where the score does not matter. Challenge is a timed daily puzzle where the no-undo constraint is the difficulty mechanic.
|
||||
|
||||
**In our wrapper:** Add `GameMode` to `solitaire_core::GameState`; intercept undo calls and scoring deltas in the adapter before delegating to `KlondikeState`.
|
||||
|
||||
### 3. Solvability Solver *(upstream merged — card_game v0.4.0)*
|
||||
`card_game v0.4.0` ships `Session::solve()` — a budget-bounded DFS that returns `Result<Option<Solution<G>>, SolveError>`. `SolveError` has two variants:
|
||||
- `MovesBudgetExceeded` — equivalent to our `SolverResult::Inconclusive`
|
||||
- `StatesBudgetExceeded` — equivalent to our `SolverResult::Inconclusive`
|
||||
|
||||
`Solution<G>` contains the winning move sequence as `Vec<StateSnapshot<G>>`; `clean_solution()` removes cycles. `Session::solve()` uses `SessionConfig::solve_moves_budget` and `SessionConfig::solve_states_budget` (defaults: 100 000 each).
|
||||
|
||||
Our 767-line `solitaire_core::solver` reimplements the full game rules to run the DFS; `session.solve()` replaces it entirely. The solver will be removed once the `Session<Klondike>` is wired into `GameState`.
|
||||
|
||||
**In our wrapper:** Replace `solitaire_core::solver` with `session.solve()`. Map `Ok(Some(_))` → Winnable, `Ok(None)` → Unwinnable, `Err(_)` → Inconclusive.
|
||||
|
||||
### 4. `take_from_foundation` House Rule *(upstream merged — v0.3.0)*
|
||||
`MoveFromFoundationConfig` is now part of `KlondikeConfig`. When set to `Disallowed`, `is_instruction_valid` blocks foundation → tableau instructions.
|
||||
|
||||
**Important:** The upstream default is `MoveFromFoundationConfig::Allowed`. Ferrous Solitaire uses the standard rule (foundation cards cannot be moved back) as the default, with the house rule as an opt-in. Our adapter explicitly sets `Disallowed` in the default `KlondikeConfig` and switches to `Allowed` only when the user toggles the house-rule option.
|
||||
|
||||
**In our wrapper:** Construct `KlondikeConfig { move_from_foundation: MoveFromFoundationConfig::Disallowed, .. }` by default; mirror the user's settings toggle to `Allowed`. No custom intercept needed — `klondike` enforces the rule automatically.
|
||||
|
||||
### 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.
|
||||
|
||||
**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.
|
||||
|
||||
### 6. Typed Move Errors
|
||||
`solitaire_core::error::MoveError` returns structured errors the engine uses to trigger UI feedback (wrong-destination toast, stock-empty chime, etc.):
|
||||
|
||||
```
|
||||
GameAlreadyWon
|
||||
UndoStackEmpty
|
||||
StockEmpty
|
||||
InvalidSource
|
||||
InvalidDestination
|
||||
RuleViolation(String)
|
||||
```
|
||||
|
||||
`KlondikeInstruction` is always constructed by game code from valid entity layout, so invalid moves are only detectable at `solitaire_core`'s construction boundary — the error lives there, not inside `klondike`.
|
||||
|
||||
**In our wrapper:** `MoveError` variants are generated when `solitaire_core` fails to construct a `KlondikeInstruction` from the player's requested move. No translation of `is_instruction_valid`'s bool return is required; by the time an instruction reaches `klondike`, it is already known to be structurally valid.
|
||||
|
||||
### 7. Waste Pile as Separate Concept
|
||||
Ferrous tracks `PileType::Waste` as a distinct pile. `klondike` folds waste into `Stock` (the face-up half of the stock `Pile`). The engine's UI and scoring logic reference the waste pile directly; the mapping needs to be explicit.
|
||||
|
||||
**In our wrapper:** Project the face-up half of `klondike`'s stock `Pile` as `PileType::Waste` when building pile snapshots for the engine.
|
||||
|
||||
### 8. Undo Stack Approach *(resolved — not an issue)*
|
||||
`card_game v0.4.0` `Session` uses snapshot-based undo: `SessionState` stores `Vec<StateSnapshot<G>>` where each entry holds the pre-move game state and the instruction. Undo pops the last snapshot and restores state directly — O(1), matching our existing `GameState.undo_stack`.
|
||||
|
||||
**Resolution:** Use `Session`'s built-in snapshot history. Our `GameState.undo_stack: VecDeque<StateSnapshot>` will be removed once `GameState` is fully migrated to delegate to `Session`.
|
||||
|
||||
---
|
||||
|
||||
## Integration Path (All work in `solitaire_core`)
|
||||
|
||||
Steps in dependency order. Upstream issues #10, #11, and the solver are all merged.
|
||||
|
||||
1. ✅ **Add `klondike = "0.3.0"` / `card_game = "0.4.0"` as dependencies** of `solitaire_core`; `KlondikeAdapter` wraps `KlondikeConfig` and exposes scoring helpers.
|
||||
2. **Map pile types** — project `klondike`'s stock face-up half as `PileType::Waste`; expose the same `HashMap<PileType, Pile>` the engine already reads. Wire `Session<Klondike>` into `KlondikeAdapter` (gap 7).
|
||||
3. ✅ **Configure `KlondikeConfig`** — set `move_from_foundation: MoveFromFoundationConfig::Disallowed` by default; wire the user's house-rule toggle to `Allowed` (gap 4, upstream).
|
||||
4. ✅ **Port scoring** — pass WXP deltas into `ScoringConfig`; `SessionConfig::undo_penalty` handles undo; implement recycle-with-free-allowance, score floor, and time bonus in the adapter (gap 1).
|
||||
5. ✅ **Port `GameMode`** — intercept undo + scoring in the adapter based on mode (gap 2).
|
||||
6. **Replace solver** — call `session.solve()` with budgets from our `SolverConfig`; map `Ok(Some)` → Winnable, `Ok(None)` → Unwinnable, `Err` → Inconclusive (gap 3, upstream).
|
||||
7. **Implement `serde`** — define `SavedInstruction` + `SavedStateSnapshot` newtypes; serialise session history; migrate save-file schema (gap 5).
|
||||
|
||||
---
|
||||
|
||||
## 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_sync` merge logic — operates on a `SyncPayload` DTO, independent of core card types.
|
||||
- The `solitaire_server` — speaks only `SyncPayload` JSON, unaffected.
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- Quaternions' repo: <https://git.aleshym.co/Quaternions/card_game>
|
||||
- `card_game v0.4.0` release commit: `fa098f0d`
|
||||
- `klondike v0.3.0` release commit: `f4c4e350`
|
||||
- Upstream scoring + config PRs: #12 (closes #11), #13 (closes #10)
|
||||
- Upstream solver PR: #14
|
||||
- `solitaire_core` source: `solitaire_core/src/`
|
||||
- Scoring spec: `solitaire_core/src/scoring.rs`
|
||||
- Architecture overview: `ARCHITECTURE.md`
|
||||
@@ -62,6 +62,21 @@ pub enum SyncBackend {
|
||||
},
|
||||
}
|
||||
|
||||
/// Touch input mode — controls what a single tap on a face-up card does.
|
||||
///
|
||||
/// Defaults to `OneTap` so existing behaviour is unchanged on upgrade.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub enum TouchInputMode {
|
||||
/// A single tap immediately moves the card to its best destination
|
||||
/// (foundation-first, then tableau). This is the original behaviour.
|
||||
#[default]
|
||||
OneTap,
|
||||
/// A first tap *selects* the card/stack and highlights it; a second
|
||||
/// tap on a valid destination pile performs the move. Tapping the
|
||||
/// selection again, or an empty / invalid target, cancels without moving.
|
||||
TapToSelect,
|
||||
}
|
||||
|
||||
/// Persisted window size (in logical pixels) and screen position
|
||||
/// (top-left corner, in physical pixels) — restored on next launch.
|
||||
///
|
||||
@@ -264,6 +279,13 @@ pub struct Settings {
|
||||
/// Defaults to `1` (the first site created in a fresh Matomo install).
|
||||
#[serde(default = "default_matomo_site_id")]
|
||||
pub matomo_site_id: u32,
|
||||
/// Touch input mode — `OneTap` (default) auto-moves on first tap;
|
||||
/// `TapToSelect` requires an explicit destination tap. Only affects
|
||||
/// touch/Android; desktop mouse input is unchanged. Older
|
||||
/// `settings.json` files deserialize cleanly to `OneTap` via
|
||||
/// `#[serde(default)]`.
|
||||
#[serde(default)]
|
||||
pub touch_input_mode: TouchInputMode,
|
||||
}
|
||||
|
||||
fn default_draw_mode() -> DrawMode {
|
||||
@@ -397,6 +419,7 @@ impl Default for Settings {
|
||||
analytics_enabled: false,
|
||||
matomo_url: None,
|
||||
matomo_site_id: default_matomo_site_id(),
|
||||
touch_input_mode: TouchInputMode::OneTap,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +93,9 @@ fn handle_start_challenge_request(
|
||||
return;
|
||||
}
|
||||
let Some(seed) = challenge_seed_for(progress.0.challenge_index) else {
|
||||
warn!("challenge seed list is empty");
|
||||
info_toast.write(InfoToastEvent(
|
||||
"You've completed all challenges! More coming soon.".into(),
|
||||
));
|
||||
return;
|
||||
};
|
||||
new_game.write(NewGameRequestEvent {
|
||||
|
||||
@@ -21,7 +21,8 @@ use crate::{
|
||||
RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin, SafeAreaInsetsPlugin,
|
||||
SelectionPlugin, SettingsPlugin, SplashPlugin, StatsPlugin, SyncPlugin, SyncProvider,
|
||||
SyncSetupPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin,
|
||||
UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
|
||||
TouchSelectionPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin,
|
||||
WinSummaryPlugin,
|
||||
};
|
||||
|
||||
/// Groups all Ferrous Solitaire gameplay plugins.
|
||||
@@ -83,6 +84,7 @@ impl Plugin for CoreGamePlugin {
|
||||
.add_plugins(InputPlugin)
|
||||
.add_plugins(RadialMenuPlugin)
|
||||
.add_plugins(SelectionPlugin)
|
||||
.add_plugins(TouchSelectionPlugin)
|
||||
.add_plugins(AnimationPlugin)
|
||||
.add_plugins(FeedbackAnimPlugin)
|
||||
.add_plugins(CardAnimationPlugin)
|
||||
|
||||
@@ -31,6 +31,7 @@ use solitaire_core::game_state::GameState;
|
||||
use solitaire_core::pile::PileType;
|
||||
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
||||
|
||||
use crate::auto_complete_plugin::AutoCompleteState;
|
||||
use crate::card_animation::tuning::AnimationTuning;
|
||||
use crate::card_animation::{CardAnimation, MotionCurve};
|
||||
use crate::card_plugin::{CardEntity, HintHighlight, HintHighlightTimer, STACK_FAN_FRAC};
|
||||
@@ -48,7 +49,9 @@ use crate::radial_menu::RightClickRadialState;
|
||||
use crate::replay_playback::ReplayPlaybackState;
|
||||
use crate::resources::{DragState, GameInputConsumedResource, GameStateResource, HintCycleIndex};
|
||||
use crate::selection_plugin::SelectionState;
|
||||
use crate::settings_plugin::SettingsResource;
|
||||
use crate::time_attack_plugin::TimeAttackResource;
|
||||
use crate::touch_selection_plugin::TouchSelectionState;
|
||||
use crate::ui_theme::{MOTION_DRAG_REJECT_SECS, STATE_SUCCESS, STATE_WARNING};
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
|
||||
@@ -570,6 +573,7 @@ fn start_drag(
|
||||
buttons: Res<ButtonInput<MouseButton>>,
|
||||
touches: Option<Res<Touches>>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
auto_complete: Option<Res<AutoCompleteState>>,
|
||||
windows: Query<&Window, With<PrimaryWindow>>,
|
||||
cameras: Query<(&Camera, &GlobalTransform)>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
@@ -579,6 +583,9 @@ fn start_drag(
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
if auto_complete.is_some_and(|ac| ac.active) {
|
||||
return;
|
||||
}
|
||||
// Only start a new drag when idle (no touch drag running either).
|
||||
if !buttons.just_pressed(MouseButton::Left) || !drag.is_idle() {
|
||||
return;
|
||||
@@ -856,6 +863,7 @@ fn end_drag(
|
||||
fn touch_start_drag(
|
||||
mut touch_events: MessageReader<TouchInput>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
auto_complete: Option<Res<AutoCompleteState>>,
|
||||
cameras: Query<(&Camera, &GlobalTransform)>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
game: Res<GameStateResource>,
|
||||
@@ -864,6 +872,9 @@ fn touch_start_drag(
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
if auto_complete.is_some_and(|ac| ac.active) {
|
||||
return;
|
||||
}
|
||||
// Only one drag at a time.
|
||||
if !drag.is_idle() {
|
||||
return;
|
||||
@@ -1501,13 +1512,18 @@ fn handle_double_tap(
|
||||
mut touch_events: MessageReader<TouchInput>,
|
||||
paused: Option<Res<PausedResource>>,
|
||||
radial: Option<Res<RightClickRadialState>>,
|
||||
auto_complete: Option<Res<AutoCompleteState>>,
|
||||
drag: Res<DragState>,
|
||||
game: Res<GameStateResource>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
mut touch_selection: Option<ResMut<TouchSelectionState>>,
|
||||
mut moves: MessageWriter<MoveRequestEvent>,
|
||||
mut rejected: MessageWriter<MoveRejectedEvent>,
|
||||
mut commands: Commands,
|
||||
mut card_sprites: Query<(Entity, &CardEntity, &mut Sprite)>,
|
||||
) {
|
||||
use solitaire_data::settings::TouchInputMode;
|
||||
|
||||
if paused.is_some_and(|p| p.0) {
|
||||
return;
|
||||
}
|
||||
@@ -1516,6 +1532,10 @@ fn handle_double_tap(
|
||||
if radial.is_some_and(|r| r.is_active()) {
|
||||
return;
|
||||
}
|
||||
// Auto-complete owns all moves during its sequence.
|
||||
if auto_complete.is_some_and(|ac| ac.active) {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(active_id) = drag.active_touch_id else {
|
||||
return;
|
||||
@@ -1524,6 +1544,10 @@ fn handle_double_tap(
|
||||
return;
|
||||
}
|
||||
|
||||
let tap_to_select = settings
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.0.touch_input_mode == TouchInputMode::TapToSelect);
|
||||
|
||||
for event in touch_events.read() {
|
||||
if event.id != active_id || event.phase != TouchPhase::Ended {
|
||||
continue;
|
||||
@@ -1533,10 +1557,10 @@ fn handle_double_tap(
|
||||
let Some(&top_card_id) = drag.cards.last() else {
|
||||
return;
|
||||
};
|
||||
let Some(ref pile) = drag.origin_pile else {
|
||||
let Some(ref tapped_pile) = drag.origin_pile else {
|
||||
return;
|
||||
};
|
||||
let Some(pile_cards) = game.0.piles.get(pile) else {
|
||||
let Some(pile_cards) = game.0.piles.get(tapped_pile) else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -1547,6 +1571,34 @@ fn handle_double_tap(
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Tap-to-select mode ---
|
||||
if tap_to_select {
|
||||
if let Some(ref mut sel) = touch_selection {
|
||||
if let Some((ref source_pile, ref source_cards)) = sel.selected.clone() {
|
||||
// Second tap: this is the destination.
|
||||
if tapped_pile == source_pile {
|
||||
// Re-tap on selected source → cancel.
|
||||
sel.clear();
|
||||
return;
|
||||
}
|
||||
// Attempt the move. MoveRequestEvent carries validation;
|
||||
// a rejection will fire MoveRejectedEvent automatically.
|
||||
moves.write(MoveRequestEvent {
|
||||
from: source_pile.clone(),
|
||||
to: tapped_pile.clone(),
|
||||
count: source_cards.len(),
|
||||
});
|
||||
sel.clear();
|
||||
return;
|
||||
}
|
||||
// First tap: select the source.
|
||||
sel.set(tapped_pile.clone(), drag.cards.clone());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// --- One-tap auto-move (original behaviour) ---
|
||||
|
||||
// Priority 1: move single top card.
|
||||
if let Some(dest) = best_destination(top_card, &game.0) {
|
||||
for (entity, ce, mut sprite) in card_sprites.iter_mut() {
|
||||
@@ -1559,7 +1611,7 @@ fn handle_double_tap(
|
||||
}
|
||||
}
|
||||
moves.write(MoveRequestEvent {
|
||||
from: pile.clone(),
|
||||
from: tapped_pile.clone(),
|
||||
to: dest,
|
||||
count: 1,
|
||||
});
|
||||
@@ -1571,7 +1623,7 @@ fn handle_double_tap(
|
||||
let stack_index = pile_cards.cards.len() - drag.cards.len();
|
||||
if let Some(bottom_card) = pile_cards.cards.get(stack_index)
|
||||
&& let Some((dest, count)) =
|
||||
best_tableau_destination_for_stack(bottom_card, pile, &game.0, drag.cards.len())
|
||||
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) {
|
||||
@@ -1582,7 +1634,7 @@ fn handle_double_tap(
|
||||
}
|
||||
}
|
||||
moves.write(MoveRequestEvent {
|
||||
from: pile.clone(),
|
||||
from: tapped_pile.clone(),
|
||||
to: dest,
|
||||
count,
|
||||
});
|
||||
@@ -1591,8 +1643,8 @@ fn handle_double_tap(
|
||||
}
|
||||
|
||||
rejected.write(MoveRejectedEvent {
|
||||
from: pile.clone(),
|
||||
to: pile.clone(),
|
||||
from: tapped_pile.clone(),
|
||||
to: tapped_pile.clone(),
|
||||
count: drag.cards.len(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ pub mod sync_setup_plugin;
|
||||
pub mod table_plugin;
|
||||
pub mod theme;
|
||||
pub mod time_attack_plugin;
|
||||
pub mod touch_selection_plugin;
|
||||
pub mod ui_focus;
|
||||
pub mod ui_modal;
|
||||
pub mod ui_theme;
|
||||
@@ -142,6 +143,7 @@ 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,
|
||||
|
||||
@@ -141,6 +141,10 @@ struct HighContrastText;
|
||||
#[derive(Component, Debug)]
|
||||
struct ReduceMotionText;
|
||||
|
||||
/// Marks the `Text` node showing the current touch input mode state.
|
||||
#[derive(Component, Debug)]
|
||||
struct TouchInputModeText;
|
||||
|
||||
/// Marks the `Text` node showing the live tooltip-delay value.
|
||||
#[derive(Component, Debug)]
|
||||
struct TooltipDelayText;
|
||||
@@ -230,6 +234,10 @@ enum SettingsButton {
|
||||
/// non-essential motion (card-slide animations become instant
|
||||
/// snaps) per `design-system.md` §Accessibility (#3).
|
||||
ToggleReduceMotion,
|
||||
/// Toggle [`Settings::touch_input_mode`] between `OneTap`
|
||||
/// (auto-move on tap, default) and `TapToSelect` (first tap selects
|
||||
/// a card/stack, second tap on a target pile moves it).
|
||||
ToggleTouchInputMode,
|
||||
/// Toggle the [`Settings::winnable_deals_only`] flag. When on, new
|
||||
/// random Classic-mode deals are filtered through
|
||||
/// [`solitaire_core::solver::try_solve`] until one is provably
|
||||
@@ -303,6 +311,7 @@ impl SettingsButton {
|
||||
// run before continuing to the picker rows.
|
||||
SettingsButton::ToggleHighContrast => 61,
|
||||
SettingsButton::ToggleReduceMotion => 62,
|
||||
SettingsButton::ToggleTouchInputMode => 63,
|
||||
// Picker rows — every swatch in a row shares the row's
|
||||
// priority so entity-index tiebreaking yields left → right.
|
||||
SettingsButton::SelectCardBack(_) => 70,
|
||||
@@ -405,11 +414,17 @@ impl Plugin for SettingsPlugin {
|
||||
update_high_contrast_borders.run_if(resource_changed::<SettingsResource>),
|
||||
update_high_contrast_backgrounds.run_if(resource_changed::<SettingsResource>),
|
||||
update_reduce_motion_text,
|
||||
update_touch_input_mode_text,
|
||||
update_tooltip_delay_text,
|
||||
update_time_bonus_multiplier_text,
|
||||
update_replay_move_interval_text,
|
||||
update_winnable_deals_only_text,
|
||||
update_smart_default_size_text,
|
||||
),
|
||||
);
|
||||
app.add_systems(
|
||||
Update,
|
||||
(
|
||||
update_analytics_enabled_text,
|
||||
attach_focusable_to_settings_buttons,
|
||||
),
|
||||
@@ -769,6 +784,18 @@ fn update_reduce_motion_text(
|
||||
}
|
||||
}
|
||||
|
||||
fn update_touch_input_mode_text(
|
||||
settings: Res<SettingsResource>,
|
||||
mut text_nodes: Query<&mut Text, With<TouchInputModeText>>,
|
||||
) {
|
||||
if !settings.is_changed() {
|
||||
return;
|
||||
}
|
||||
for mut text in &mut text_nodes {
|
||||
**text = touch_input_mode_label(&settings.0.touch_input_mode);
|
||||
}
|
||||
}
|
||||
|
||||
/// Refreshes the live "Winnable deals only" toggle value in the
|
||||
/// Gameplay section whenever `SettingsResource` changes (button click,
|
||||
/// hand-edited `settings.json` reload, etc.).
|
||||
@@ -1177,6 +1204,16 @@ fn handle_settings_buttons(
|
||||
**t = on_off_label(settings.0.reduce_motion_mode);
|
||||
}
|
||||
}
|
||||
SettingsButton::ToggleTouchInputMode => {
|
||||
use solitaire_data::settings::TouchInputMode;
|
||||
settings.0.touch_input_mode = match settings.0.touch_input_mode {
|
||||
TouchInputMode::OneTap => TouchInputMode::TapToSelect,
|
||||
TouchInputMode::TapToSelect => TouchInputMode::OneTap,
|
||||
};
|
||||
persist(&path, &settings.0);
|
||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||
// Text refreshed by `update_touch_input_mode_text` next frame.
|
||||
}
|
||||
SettingsButton::ToggleWinnableDealsOnly => {
|
||||
settings.0.winnable_deals_only = !settings.0.winnable_deals_only;
|
||||
persist(&path, &settings.0);
|
||||
@@ -1311,6 +1348,14 @@ fn winnable_deals_only_label(enabled: bool) -> String {
|
||||
if enabled { "ON".into() } else { "OFF".into() }
|
||||
}
|
||||
|
||||
fn touch_input_mode_label(mode: &solitaire_data::settings::TouchInputMode) -> String {
|
||||
use solitaire_data::settings::TouchInputMode;
|
||||
match mode {
|
||||
TouchInputMode::OneTap => "One-tap".into(),
|
||||
TouchInputMode::TapToSelect => "Tap to select".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Display string for the "Smart window size" toggle. The argument
|
||||
/// is the *enabled* state (i.e. the inverse of the underlying
|
||||
/// `disable_smart_default_size` field) so reading the label gives
|
||||
@@ -1761,6 +1806,15 @@ fn spawn_settings_panel(
|
||||
"Skips card-slide animations and other non-essential motion. Cards snap instantly to their target.",
|
||||
font_res,
|
||||
);
|
||||
toggle_row(
|
||||
body,
|
||||
"Touch Input Mode",
|
||||
TouchInputModeText,
|
||||
touch_input_mode_label(&settings.touch_input_mode),
|
||||
SettingsButton::ToggleTouchInputMode,
|
||||
"One-tap: tap a card to auto-move it. Tap to select: first tap selects a card, second tap on a pile moves it.",
|
||||
font_res,
|
||||
);
|
||||
if theme_overrides_back {
|
||||
// The active theme provides its own back; the legacy
|
||||
// picker has no visible effect, so we replace its
|
||||
|
||||
@@ -29,7 +29,7 @@ use crate::progress_plugin::ProgressResource;
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::time_attack_plugin::TimeAttackResource;
|
||||
use crate::ui_modal::{
|
||||
ButtonVariant, ModalButton, ScrimDismissible, spawn_modal, spawn_modal_actions,
|
||||
ButtonVariant, ModalButton, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
|
||||
spawn_modal_button, spawn_modal_header,
|
||||
};
|
||||
use crate::ui_theme::{
|
||||
@@ -649,6 +649,7 @@ fn toggle_stats_screen(
|
||||
latest_replay: Res<ReplayHistoryResource>,
|
||||
selected_index: Res<SelectedReplayIndex>,
|
||||
screens: Query<Entity, With<StatsScreen>>,
|
||||
other_modal_scrims: Query<(), (With<ModalScrim>, Without<StatsScreen>)>,
|
||||
) {
|
||||
let button_clicked = requests.read().count() > 0;
|
||||
if !keys.just_pressed(KeyCode::KeyS) && !button_clicked {
|
||||
@@ -657,6 +658,9 @@ fn toggle_stats_screen(
|
||||
if let Ok(entity) = screens.single() {
|
||||
commands.entity(entity).despawn();
|
||||
} else {
|
||||
if !other_modal_scrims.is_empty() {
|
||||
return;
|
||||
}
|
||||
spawn_stats_screen(
|
||||
&mut commands,
|
||||
&stats.0,
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Touch tap-to-select input mode.
|
||||
//!
|
||||
//! When [`TouchInputMode::TapToSelect`] is active (set via [`crate::settings_plugin`]),
|
||||
//! a single tap on a face-up card **selects** it (showing a visual highlight) instead
|
||||
//! of immediately auto-moving it. A second tap on a valid destination pile performs
|
||||
//! the move; a second tap on the same pile (or an invalid target) cancels silently.
|
||||
//!
|
||||
//! In [`TouchInputMode::OneTap`] mode this plugin is fully passive — all resources
|
||||
//! default to their empty state and no highlight is ever shown.
|
||||
//!
|
||||
//! ## State machine
|
||||
//!
|
||||
//! ```text
|
||||
//! Idle ──(tap face-up card)──> Selected(pile, cards)
|
||||
//! ↑ │
|
||||
//! │ cancel (re-tap or │ second tap on destination
|
||||
//! └── StateChangedEvent) ◄──────┤ → MoveRequestEvent; back to Idle
|
||||
//! │
|
||||
//! └── rejected / no destination → back to Idle
|
||||
//! ```
|
||||
//!
|
||||
//! ## Interaction with the existing auto-move flow
|
||||
//!
|
||||
//! [`crate::input_plugin::handle_double_tap`] is the entry point: it reads
|
||||
//! [`TouchSelectionState`] and, in `TapToSelect` mode, populates it on the first
|
||||
//! tap instead of firing `MoveRequestEvent`. This plugin owns the highlight visual
|
||||
//! and the state-clear reactions.
|
||||
|
||||
use bevy::ecs::message::MessageReader;
|
||||
use bevy::prelude::*;
|
||||
use solitaire_core::pile::PileType;
|
||||
|
||||
use crate::card_plugin::CardEntity;
|
||||
use crate::events::StateChangedEvent;
|
||||
use crate::game_plugin::GameMutation;
|
||||
use crate::layout::LayoutResource;
|
||||
use crate::ui_theme::ACCENT_PRIMARY;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// State for the tap-to-select touch flow.
|
||||
///
|
||||
/// `selected` is `Some((source_pile, card_ids))` while the player has
|
||||
/// chosen a source but not yet tapped a destination. `None` is the idle state.
|
||||
///
|
||||
/// `card_ids` mirrors `DragState::cards` — the bottom-to-top ordered list of
|
||||
/// card ids that will be moved (1 for a single card, multiple for a face-up run).
|
||||
#[derive(Resource, Debug, Default)]
|
||||
pub struct TouchSelectionState {
|
||||
/// Currently selected source pile and the card ids to move (bottom-to-top).
|
||||
pub selected: Option<(PileType, Vec<u32>)>,
|
||||
}
|
||||
|
||||
impl TouchSelectionState {
|
||||
/// Returns `true` when a source is selected.
|
||||
pub fn has_selection(&self) -> bool {
|
||||
self.selected.is_some()
|
||||
}
|
||||
|
||||
/// Takes the current selection, leaving `selected` as `None`.
|
||||
pub fn take(&mut self) -> Option<(PileType, Vec<u32>)> {
|
||||
self.selected.take()
|
||||
}
|
||||
|
||||
/// Sets the current selection.
|
||||
pub fn set(&mut self, pile: PileType, cards: Vec<u32>) {
|
||||
self.selected = Some((pile, cards));
|
||||
}
|
||||
|
||||
/// Clears the selection without returning it.
|
||||
pub fn clear(&mut self) {
|
||||
self.selected = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[derive(Component)]
|
||||
pub struct TouchSelectionHighlight;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Registers all resources and systems for the touch tap-to-select flow.
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Systems
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Clears [`TouchSelectionState`] whenever the board changes (undo, new game,
|
||||
/// won, forfeit). This prevents stale selections surviving across game resets.
|
||||
pub(crate) fn clear_touch_selection_on_state_change(
|
||||
mut selection: ResMut<TouchSelectionState>,
|
||||
mut state_events: MessageReader<StateChangedEvent>,
|
||||
) {
|
||||
if state_events.read().next().is_some() {
|
||||
selection.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
pub(crate) fn update_touch_selection_highlight(
|
||||
mut commands: Commands,
|
||||
selection: Res<TouchSelectionState>,
|
||||
card_entities: Query<(Entity, &CardEntity)>,
|
||||
highlights: Query<Entity, With<TouchSelectionHighlight>>,
|
||||
layout: Option<Res<LayoutResource>>,
|
||||
) {
|
||||
// Despawn stale highlights first.
|
||||
for entity in &highlights {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
|
||||
let Some((_, ref card_ids)) = selection.selected else {
|
||||
return;
|
||||
};
|
||||
let Some(layout) = layout else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Highlight every card in the selected stack (bottom-to-top order).
|
||||
// The bottom card of the run is the most visually important anchor,
|
||||
// but highlighting the whole run gives the player clear confirmation
|
||||
// of how many cards are involved in the move.
|
||||
let card_size = layout.0.card_size;
|
||||
for &card_id in card_ids {
|
||||
spawn_touch_highlight(&mut commands, &card_entities, card_id, card_size);
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns a [`TouchSelectionHighlight`] sprite as a child of the matching card entity.
|
||||
fn spawn_touch_highlight(
|
||||
commands: &mut Commands,
|
||||
card_entities: &Query<(Entity, &CardEntity)>,
|
||||
card_id: u32,
|
||||
card_size: Vec2,
|
||||
) {
|
||||
for (entity, card_entity) in card_entities {
|
||||
if card_entity.card_id == card_id {
|
||||
commands.entity(entity).with_children(|b| {
|
||||
b.spawn((
|
||||
TouchSelectionHighlight,
|
||||
Sprite {
|
||||
color: ACCENT_PRIMARY.with_alpha(0.55),
|
||||
custom_size: Some(card_size + Vec2::splat(6.0)),
|
||||
..default()
|
||||
},
|
||||
Transform::from_xyz(0.0, 0.0, -0.01),
|
||||
Visibility::default(),
|
||||
));
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn selection_state_default_is_idle() {
|
||||
let state = TouchSelectionState::default();
|
||||
assert!(!state.has_selection());
|
||||
assert!(state.selected.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_and_take_roundtrip() {
|
||||
let mut state = TouchSelectionState::default();
|
||||
state.set(PileType::Tableau(0), vec![1, 2, 3]);
|
||||
assert!(state.has_selection());
|
||||
let taken = state.take();
|
||||
assert!(taken.is_some());
|
||||
let (pile, cards) = taken.unwrap();
|
||||
assert_eq!(pile, PileType::Tableau(0));
|
||||
assert_eq!(cards, vec![1, 2, 3]);
|
||||
assert!(!state.has_selection());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_removes_selection() {
|
||||
let mut state = TouchSelectionState::default();
|
||||
state.set(PileType::Waste, vec![42]);
|
||||
state.clear();
|
||||
assert!(!state.has_selection());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn take_on_idle_returns_none() {
|
||||
let mut state = TouchSelectionState::default();
|
||||
assert!(state.take().is_none());
|
||||
assert!(!state.has_selection());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_overwrites_previous_selection() {
|
||||
let mut state = TouchSelectionState::default();
|
||||
state.set(PileType::Tableau(0), vec![1]);
|
||||
state.set(PileType::Tableau(3), vec![7, 8]);
|
||||
let (pile, cards) = state.take().unwrap();
|
||||
assert_eq!(pile, PileType::Tableau(3));
|
||||
assert_eq!(cards, vec![7, 8]);
|
||||
}
|
||||
}
|
||||
@@ -156,6 +156,8 @@ pub async fn upload(
|
||||
|
||||
// Update leaderboard best score/time for opted-in users when this replay
|
||||
// beats their existing best. Only classic mode counts for the leaderboard.
|
||||
// Use `received_at` (server-computed) rather than `header.recorded_at`
|
||||
// (client-supplied) so clients cannot spoof the timestamp.
|
||||
if header.mode == "Classic" {
|
||||
sqlx::query!(
|
||||
r#"UPDATE leaderboard
|
||||
@@ -170,7 +172,7 @@ pub async fn upload(
|
||||
)"#,
|
||||
header.final_score,
|
||||
header.time_seconds,
|
||||
header.recorded_at,
|
||||
received_at,
|
||||
user.user_id,
|
||||
header.final_score,
|
||||
header.final_score,
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
use axum::{Json, extract::State};
|
||||
use chrono::Utc;
|
||||
use sqlx::SqlitePool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use solitaire_sync::{
|
||||
AchievementRecord, PlayerProgress, StatsSnapshot, SyncPayload, SyncResponse, merge,
|
||||
@@ -142,10 +143,19 @@ pub async fn pull(
|
||||
pub async fn push(
|
||||
State(state): State<AppState>,
|
||||
user: AuthenticatedUser,
|
||||
Json(client_payload): Json<SyncPayload>,
|
||||
Json(mut client_payload): Json<SyncPayload>,
|
||||
) -> Result<Json<SyncResponse>, AppError> {
|
||||
// Reject payloads that claim to belong to a different user.
|
||||
if client_payload.user_id.to_string() != user.user_id {
|
||||
let user_uuid: Uuid = user
|
||||
.user_id
|
||||
.parse()
|
||||
.map_err(|_| AppError::Internal("invalid user_id UUID in JWT".into()))?;
|
||||
|
||||
// The desktop client always sends Uuid::nil() as a placeholder for the
|
||||
// authenticated user's real ID (see build_payload docstring). Replace it
|
||||
// here. Reject payloads that explicitly claim a different user's identity.
|
||||
if client_payload.user_id == Uuid::nil() {
|
||||
client_payload.user_id = user_uuid;
|
||||
} else if client_payload.user_id != user_uuid {
|
||||
return Err(AppError::BadRequest("user_id mismatch".into()));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user