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:
@@ -165,6 +165,29 @@ pub struct CardEntity {
|
||||
pub card: Card,
|
||||
}
|
||||
|
||||
/// Render-side index mapping each live board card to its [`CardEntity`].
|
||||
///
|
||||
/// Maintained exclusively by [`rebuild_card_entity_index`] in `PostUpdate`,
|
||||
/// after the card-sync authority ([`sync_cards_on_change`]) and the resize
|
||||
/// re-snap have flushed their spawn/despawn `Commands`. Consumers treat it as
|
||||
/// read-only and must still call `Query::get(entity)` for components beyond the
|
||||
/// `Entity` id (the map yields only the id).
|
||||
///
|
||||
/// Keyed by `Card`, which is unique across all live board entities in
|
||||
/// single-deck Klondike. Transient entities (drag shadow, labels) carry no
|
||||
/// `CardEntity` component, so the rebuild — which scans `&CardEntity` — never
|
||||
/// records them.
|
||||
#[derive(Resource, Debug, Default)]
|
||||
pub struct CardEntityIndex(pub HashMap<Card, Entity>);
|
||||
|
||||
impl CardEntityIndex {
|
||||
/// Resolve a card to its live entity, if one is currently spawned.
|
||||
#[inline]
|
||||
pub fn get(&self, card: &Card) -> Option<Entity> {
|
||||
self.0.get(card).copied()
|
||||
}
|
||||
}
|
||||
|
||||
/// Marker for the text child inside a card entity.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct CardLabel;
|
||||
@@ -431,6 +454,7 @@ impl Plugin for CardPlugin {
|
||||
// ensure it exists here. Under `DefaultPlugins` the call is a no-op.
|
||||
app.init_resource::<ButtonInput<MouseButton>>()
|
||||
.init_resource::<ResizeThrottle>()
|
||||
.init_resource::<CardEntityIndex>()
|
||||
.add_message::<SettingsChangedEvent>()
|
||||
.add_message::<CardFlippedEvent>()
|
||||
.add_message::<CardFaceRevealedEvent>()
|
||||
@@ -466,6 +490,36 @@ impl Plugin for CardPlugin {
|
||||
);
|
||||
|
||||
app.add_systems(Update, resize_android_corner_labels);
|
||||
app.add_systems(PostUpdate, rebuild_card_entity_index);
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild the [`CardEntityIndex`] from the live `CardEntity` set.
|
||||
///
|
||||
/// Runs in `PostUpdate` so that all spawn/despawn `Commands` issued by
|
||||
/// [`sync_cards_on_change`] and [`snap_cards_on_window_resize`] in `Update`
|
||||
/// have been flushed at the `Update -> PostUpdate` apply-deferred boundary.
|
||||
/// Rebuilding from scratch (rather than incrementally patching at every
|
||||
/// spawn/despawn site — waste cards churn on every draw) keeps a single writer
|
||||
/// and makes a stale entry structurally impossible.
|
||||
///
|
||||
/// Gated to changed frames only: `Changed<CardEntity>` fires the frame a card
|
||||
/// is spawned, `RemovedComponents<CardEntity>` the frame one is despawned. The
|
||||
/// `card` field is write-once (never mutated in place), so card-reposition
|
||||
/// frames don't trip `Changed` and correctly skip the O(52) rebuild.
|
||||
fn rebuild_card_entity_index(
|
||||
mut index: ResMut<CardEntityIndex>,
|
||||
cards: Query<(Entity, &CardEntity)>,
|
||||
changed: Query<(), Changed<CardEntity>>,
|
||||
removed: RemovedComponents<CardEntity>,
|
||||
) {
|
||||
if changed.is_empty() && removed.is_empty() {
|
||||
return;
|
||||
}
|
||||
let map = &mut index.0;
|
||||
map.clear();
|
||||
for (entity, ce) in &cards {
|
||||
map.insert(ce.card.clone(), entity);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user