refactor(core): make KlondikeInstruction the move currency
Build and Deploy / build-and-push (push) Failing after 1m1s
Web E2E / web-e2e (push) Failing after 3m26s

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:
funman300
2026-06-10 16:58:28 -07:00
parent dc4cf45ea0
commit ef1efdc3b5
9 changed files with 219 additions and 110 deletions
+76 -26
View File
@@ -826,14 +826,24 @@ impl GameState {
}
}
/// Converts an upstream [`KlondikeInstruction`] into the engine's
/// `(from, to, count)` pile-move form, resolving multi-card tableau moves
/// against the live board. Returns `None` for no-op instructions
/// (foundation→foundation, or a tableau move of zero cards).
/// Decodes an upstream [`KlondikeInstruction`] into the `(from, to, count)`
/// pile coordinates of `solitaire_core`'s own pile model, against the live
/// board. Returns `None` for no-op instructions (foundation→foundation, or a
/// tableau move of zero cards).
///
/// Used by the hint system to render a solver's recommended first move,
/// and internally by [`Self::possible_instructions`].
pub fn instruction_to_move(
/// This is the single, canonical translation from the upstream instruction
/// move-currency to core's [`KlondikePile`] vocabulary. It lives in core
/// because decoding a tableau-run length requires upstream pile-stack types
/// (`KlondikePileStack`/`SkipCards`) that the engine and wasm crates do not
/// see — relocating it would duplicate this logic across both crates. The
/// two edges that genuinely need on-screen positions call it: the engine's
/// hint highlight (which pile to glow) and the wasm debug move list (pile
/// names + run length serialized to the browser harness).
///
/// Game logic — auto-complete, move application, the property tests — stays
/// in instruction space and never calls this; applying a move uses
/// [`Self::apply_instruction`].
pub fn instruction_to_piles(
&self,
instruction: KlondikeInstruction,
) -> Option<(KlondikePile, KlondikePile, usize)> {
@@ -931,6 +941,28 @@ impl GameState {
}
let instruction = self.instruction_for_move(from, to, count)?;
self.apply_instruction(instruction)
}
/// Apply an upstream [`KlondikeInstruction`] directly to the live session.
///
/// This is the native apply path for moves that already exist in
/// instruction form — solver hints, auto-complete, replay, and the property
/// tests. User drag-and-drop enters through [`Self::move_cards`], which is a
/// thin adapter that converts pile coordinates to an instruction and
/// delegates here, so the move bookkeeping (rule validation, score history,
/// recycle accounting, undo snapshot) lives in exactly one place.
///
/// Returns [`MoveError::RuleViolation`] if the instruction is illegal in the
/// current position, or [`MoveError::GameAlreadyWon`] once the game is over.
pub fn apply_instruction(
&mut self,
instruction: KlondikeInstruction,
) -> Result<(), MoveError> {
if self.is_won() {
return Err(MoveError::GameAlreadyWon);
}
let config = self.validation_config();
if !self
.session
@@ -941,12 +973,16 @@ impl GameState {
return Err(MoveError::RuleViolation("move violates rules".into()));
}
let (score_delta, _) = self.pre_instruction_score_delta(instruction);
let (score_delta, is_recycle) = self.pre_instruction_score_delta(instruction);
self.score_history.push(self.score);
self.is_recycle_history.push(false);
self.is_recycle_history.push(is_recycle);
self.session.process_instruction(instruction);
if is_recycle {
self.recycle_count = self.recycle_count.saturating_add(1);
}
self.score = (self.score + score_delta).max(0);
Ok(())
}
@@ -996,8 +1032,13 @@ impl GameState {
self.session.state().state().is_win_trivial()
}
/// Returns all currently valid `(from, to, count)` moves.
pub fn possible_instructions(&self) -> Vec<(KlondikePile, KlondikePile, usize)> {
/// Returns all currently valid moves as upstream [`KlondikeInstruction`]s,
/// ordered by the `klondike` solver's move priority.
///
/// This is the engine's move currency. Callers that need on-screen pile
/// positions — hint highlighting and the wasm debug move list — decode each
/// instruction with [`Self::instruction_to_piles`] at their UI edge.
pub fn possible_instructions(&self) -> Vec<KlondikeInstruction> {
if self.is_won() {
return Vec::new();
}
@@ -1008,7 +1049,6 @@ impl GameState {
.state()
.get_sorted_moves(&config)
.into_iter()
.filter_map(|instruction| self.instruction_to_move(instruction))
.collect()
}
@@ -1061,20 +1101,20 @@ impl GameState {
return None;
}
// A foundation-bound single-card move is exactly a `DstFoundation`
// instruction whose source is not itself a foundation. Match the
// instruction variant directly rather than projecting every candidate
// to `(from, to, count)` pile coordinates — auto-complete is pure game
// logic and never needs on-screen positions.
self.possible_instructions()
.into_iter()
.find_map(|(from, to, count)| {
if count != 1 {
return None;
}
if matches!(from, KlondikePile::Foundation(_)) {
return None;
}
if matches!(to, KlondikePile::Foundation(_)) {
Some((from, to))
} else {
None
.find_map(|instruction| match instruction {
KlondikeInstruction::DstFoundation(dst)
if !matches!(dst.src, KlondikePile::Foundation(_)) =>
{
Some((dst.src, KlondikePile::Foundation(dst.foundation)))
}
_ => None,
})
}
@@ -1098,6 +1138,16 @@ impl GameState {
mod tests {
use super::*;
/// Resolve every legal instruction to its `(from, to, count)` piles for
/// tests that assert against pile positions. Mirrors what a UI edge does
/// via [`GameState::instruction_to_piles`].
fn legal_pile_moves(game: &GameState) -> Vec<(KlondikePile, KlondikePile, usize)> {
game.possible_instructions()
.into_iter()
.filter_map(|instruction| game.instruction_to_piles(instruction))
.collect()
}
fn find_foundation_return_position() -> Option<(GameState, KlondikePile, KlondikePile)> {
const MAX_SEED: u64 = 512;
const MAX_STEPS: usize = 160;
@@ -1107,7 +1157,7 @@ mod tests {
game.take_from_foundation = true;
for _ in 0..MAX_STEPS {
let moves = game.possible_instructions();
let moves = legal_pile_moves(&game);
if let Some((from, to, _count)) = moves.iter().cloned().find(|(from, to, count)| {
*count == 1
&& matches!(from, KlondikePile::Foundation(_))
@@ -1226,7 +1276,7 @@ mod tests {
game.take_from_foundation = true;
assert!(game.can_move_cards(&from, &to, 1));
assert!(
game.possible_instructions()
legal_pile_moves(&game)
.iter()
.any(|(f, t, c)| *f == from && *t == to && *c == 1)
);
@@ -1241,7 +1291,7 @@ mod tests {
game.take_from_foundation = false;
assert!(!game.can_move_cards(&from, &to, 1));
assert!(
game.possible_instructions()
legal_pile_moves(&game)
.iter()
.all(|(f, t, _)| !matches!(f, KlondikePile::Foundation(_))
|| !matches!(t, KlondikePile::Tableau(_)))