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:
@@ -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(_)))
|
||||
|
||||
Reference in New Issue
Block a user