refactor(core): make KlondikeInstruction the move currency
Remove the (from, to, count) tuple as an internal move-passing wrapper. Game logic now stays in KlondikeInstruction space end to end: - Add GameState::apply_instruction, the native apply path. move_cards becomes a thin pile-coordinate adapter that converts to an instruction and delegates, so move bookkeeping (validation, score/recycle history, undo snapshot) lives in one place instead of being duplicated. - next_auto_complete_move matches DstFoundation directly instead of projecting every candidate to pile coordinates. - proptests and the storage round-trip test apply instructions directly rather than round-tripping instruction -> tuple -> move_cards. The single instruction -> pile decode is renamed instruction_to_highlight -> instruction_to_piles and kept in core: decoding a tableau run length needs upstream pile-stack types core does not re-export, so relocating it would duplicate the logic across engine and wasm. The two rendering edges (engine hint highlight, wasm debug move list) call this one decoder; the engine's hint_piles is a thin delegation to it. Also includes the CardEntityIndex render-side index and a SelectionPlugin init_resource fix so update_selection_highlight no longer panics in test harnesses that omit CardPlugin. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -26,7 +26,7 @@ use bevy::prelude::*;
|
||||
use bevy::window::PrimaryWindow;
|
||||
#[cfg(not(target_os = "android"))]
|
||||
use bevy::window::{MonitorSelection, WindowMode};
|
||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
|
||||
use solitaire_core::card::{Card, Suit};
|
||||
use solitaire_core::game_state::GameState;
|
||||
|
||||
@@ -350,7 +350,7 @@ pub fn find_heuristic_hint(
|
||||
}
|
||||
let idx = hint_cycle.0 % hints.len();
|
||||
hint_cycle.0 = hint_cycle.0.wrapping_add(1);
|
||||
let (from, to, _count) = hints[idx];
|
||||
let (from, to) = hints[idx];
|
||||
Some((from, to))
|
||||
}
|
||||
|
||||
@@ -1642,30 +1642,47 @@ fn handle_double_tap(
|
||||
/// Build the complete list of legal moves available in `game`, ordered so that
|
||||
/// 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
|
||||
/// (game is stuck).
|
||||
/// Each entry is `(from, to)` — the source and destination piles a hint
|
||||
/// should highlight. Only single-card moves are surfaced; multi-card tableau
|
||||
/// runs are filtered out by [`hint_piles`]. The list may be empty when no
|
||||
/// move exists at all (game is stuck).
|
||||
///
|
||||
/// 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)> {
|
||||
pub fn all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile)> {
|
||||
if game.has_test_pile_overrides() {
|
||||
return legacy_all_hints(game);
|
||||
}
|
||||
|
||||
game.possible_instructions()
|
||||
.into_iter()
|
||||
.filter(|(_, _, count)| *count == 1)
|
||||
.filter_map(|instruction| hint_piles(game, instruction))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Project a [`KlondikeInstruction`] to the `(source, destination)` piles a
|
||||
/// hint should highlight, or `None` for a no-op or multi-card move.
|
||||
///
|
||||
/// Delegates the instruction→pile decode to the single owner of that mapping,
|
||||
/// [`GameState::instruction_to_piles`], and keeps only single-card moves
|
||||
/// (`count == 1`) — the hint highlight can represent exactly one source card.
|
||||
pub(crate) fn hint_piles(
|
||||
game: &GameState,
|
||||
instruction: KlondikeInstruction,
|
||||
) -> Option<(KlondikePile, KlondikePile)> {
|
||||
match game.instruction_to_piles(instruction)? {
|
||||
(from, to, 1) => Some((from, to)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)> {
|
||||
fn legacy_all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile)> {
|
||||
let sources: Vec<KlondikePile> = {
|
||||
let mut s = vec![KlondikePile::Stock];
|
||||
for tableau in tableaus() {
|
||||
@@ -1674,7 +1691,7 @@ fn legacy_all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile, usize)
|
||||
s
|
||||
};
|
||||
|
||||
let mut hints: Vec<(KlondikePile, KlondikePile, usize)> = Vec::new();
|
||||
let mut hints: Vec<(KlondikePile, KlondikePile)> = Vec::new();
|
||||
|
||||
// Pass 1 — foundation moves (highest priority, shown first).
|
||||
for from in &sources {
|
||||
@@ -1685,7 +1702,7 @@ fn legacy_all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile, usize)
|
||||
for foundation in foundations() {
|
||||
let dest = KlondikePile::Foundation(foundation);
|
||||
if game.can_move_cards(from, &dest, 1) {
|
||||
hints.push((*from, dest, 1));
|
||||
hints.push((*from, dest));
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1700,14 +1717,14 @@ fn legacy_all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile, usize)
|
||||
};
|
||||
let already_has_foundation_hint = hints
|
||||
.iter()
|
||||
.any(|(f, t, _)| f == from && matches!(t, KlondikePile::Foundation(_)));
|
||||
.any(|(f, t)| f == from && matches!(t, KlondikePile::Foundation(_)));
|
||||
if already_has_foundation_hint {
|
||||
continue;
|
||||
}
|
||||
for tableau in tableaus() {
|
||||
let dest = KlondikePile::Tableau(tableau);
|
||||
if game.can_move_cards(from, &dest, 1) {
|
||||
hints.push((*from, dest, 1));
|
||||
hints.push((*from, dest));
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1727,7 +1744,7 @@ fn legacy_all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile, usize)
|
||||
for tableau in tableaus() {
|
||||
let dest = KlondikePile::Tableau(tableau);
|
||||
if game.can_move_cards(&from, &dest, 1) {
|
||||
hints.push((from, dest, 1));
|
||||
hints.push((from, dest));
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1742,9 +1759,9 @@ fn legacy_all_hints(game: &GameState) -> Vec<(KlondikePile, KlondikePile, usize)
|
||||
let waste_can_recycle = stock_cards.is_empty() && !waste_cards.is_empty();
|
||||
if stock_non_empty || waste_can_recycle {
|
||||
// Stock→Waste is not a real pile-to-pile move, but we reuse the
|
||||
// triple to signal "draw". The H handler only reads `from` to
|
||||
// pair to signal "draw". The H handler only reads `from` to
|
||||
// locate the card to highlight; we point at the stock pile.
|
||||
hints.push((KlondikePile::Stock, KlondikePile::Stock, 1));
|
||||
hints.push((KlondikePile::Stock, KlondikePile::Stock));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1793,9 +1810,9 @@ const fn tableau_number(tableau: Tableau) -> u8 {
|
||||
|
||||
/// Find one valid move in the current game state.
|
||||
///
|
||||
/// Returns `(from, to, count)` for the first legal move found, or `None` if
|
||||
/// Returns `(from, to)` for the first legal move found, or `None` if
|
||||
/// no move is available. This is a convenience wrapper over [`all_hints`].
|
||||
pub fn find_hint(game: &GameState) -> Option<(KlondikePile, KlondikePile, usize)> {
|
||||
pub fn find_hint(game: &GameState) -> Option<(KlondikePile, KlondikePile)> {
|
||||
all_hints(game).into_iter().next()
|
||||
}
|
||||
|
||||
@@ -2166,10 +2183,9 @@ mod tests {
|
||||
|
||||
let hint = find_hint(&game);
|
||||
assert!(hint.is_some(), "should find a hint");
|
||||
let (from, to, count) = hint.unwrap();
|
||||
let (from, to) = hint.unwrap();
|
||||
assert_eq!(from, KlondikePile::Tableau(Tableau::Tableau1));
|
||||
assert_eq!(to, KlondikePile::Foundation(Foundation::Foundation1));
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -2212,10 +2228,9 @@ mod tests {
|
||||
|
||||
let hints = all_hints(&game);
|
||||
assert_eq!(hints.len(), 1, "exactly one hint: draw from stock");
|
||||
let (from, to, count) = &hints[0];
|
||||
let (from, to) = &hints[0];
|
||||
assert_eq!(*from, KlondikePile::Stock, "hint must come from Stock");
|
||||
assert_eq!(*to, KlondikePile::Stock, "hint must point to Waste");
|
||||
assert_eq!(*count, 1);
|
||||
}
|
||||
|
||||
// `all_hints` must be empty when both stock and waste are empty and no
|
||||
|
||||
Reference in New Issue
Block a user