Skip to main content

manabrew_engine/
game.rs

1use std::collections::VecDeque;
2
3use forge_foundation::ZoneType;
4use serde::{Deserialize, Serialize};
5
6use crate::card::card_damage_map::CardDamageMap;
7use crate::card::card_zone_table::CardZoneTable;
8use crate::card::Card;
9use crate::ids::{CardId, PlayerId};
10use crate::phase::ExtraTurn;
11use crate::phase::TurnState;
12use crate::player::PlayerState;
13use crate::spellability::MagicStack;
14use crate::zone::{CostPaymentStack, Zone, ZoneKey, ZoneStore};
15
16/// Global registry of type lists loaded from `TypeLists.txt`.
17///
18/// Mirrors Java's `CardType.Constant.CREATURE_TYPES` etc., populated once by
19/// `FModel.loadDynamicGamedata()` → `CardType.Helper.parseTypes()`.
20///
21/// Call [`TypeRegistry::load`] once at startup with the contents of
22/// `TypeLists.txt`. All subsequent calls to [`TypeRegistry::creature_types`]
23/// return the loaded data without any per-game copying.
24pub struct TypeRegistry;
25
26static CREATURE_TYPES: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
27
28impl TypeRegistry {
29    /// Load creature types from the raw contents of `TypeLists.txt`.
30    ///
31    /// Parses the `[CreatureTypes]` section. Each line is either `TypeName` or
32    /// `TypeName:PluralName`; only the singular (left of `:`) is kept.
33    ///
34    /// Mirrors Java's `FileSection.parseSections()` + `CardType.Helper.parseTypes()`.
35    ///
36    /// This must be called once before any game starts. Subsequent calls are
37    /// silently ignored (first write wins).
38    pub fn load(type_lists_content: &str) {
39        let _ = CREATURE_TYPES.set(Self::parse_creature_types(type_lists_content));
40    }
41
42    /// Return the loaded creature types.
43    ///
44    /// # Panics
45    /// Panics if [`TypeRegistry::load`] has not been called.
46    pub fn creature_types() -> &'static [String] {
47        CREATURE_TYPES.get().expect(
48            "TypeRegistry: creature types not loaded. \
49             Call TypeRegistry::load() with the contents of TypeLists.txt before starting a game.",
50        )
51    }
52
53    /// Return whether `creature_type` is a known creature subtype.
54    ///
55    /// Unlike [`TypeRegistry::creature_types`], this is safe to call in unit
56    /// tests that haven't loaded type data yet; it simply returns `false`.
57    pub fn is_creature_type(creature_type: &str) -> bool {
58        CREATURE_TYPES.get().is_some_and(|types| {
59            types
60                .iter()
61                .any(|ty| ty.eq_ignore_ascii_case(creature_type))
62        })
63    }
64
65    fn parse_creature_types(content: &str) -> Vec<String> {
66        let mut in_creature_section = false;
67        let mut types = Vec::new();
68        for line in content.lines() {
69            let line = line.trim();
70            if line.is_empty() || line.starts_with('#') {
71                continue;
72            }
73            if line.starts_with('[') && line.ends_with(']') {
74                in_creature_section = &line[1..line.len() - 1] == "CreatureTypes";
75                continue;
76            }
77            if in_creature_section {
78                // "TypeName" or "TypeName:PluralName" — keep singular only
79                let singular = line.split(':').next().unwrap_or(line);
80                if !singular.is_empty() {
81                    types.push(singular.to_string());
82                }
83            }
84        }
85        types
86    }
87}
88
89/// The complete, serializable game state.
90/// All game entities live here — nothing holds references, everything uses IDs.
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct GameState {
93    // Arenas
94    pub cards: Vec<Card>,
95    pub players: Vec<PlayerState>,
96
97    // Zones: keyed by (ZoneType, PlayerId)
98    #[serde(skip)]
99    zones: ZoneStore,
100
101    // The stack
102    pub stack: MagicStack,
103
104    /// Cost payment tracking stack — used by triggers to inspect cost payments.
105    /// Mirrors Java's `Game.costPaymentStack`.
106    #[serde(skip)]
107    pub cost_payment_stack: CostPaymentStack,
108
109    // Day/Night cycle (Innistrad DFC mechanic)
110    pub is_night: bool,
111    pub day_night_started: bool,
112
113    // Turn/phase state
114    pub turn: TurnState,
115
116    // Player order (for turn sequence)
117    pub player_order: Vec<PlayerId>,
118
119    // Game over flag
120    pub game_over: bool,
121    pub winner: Option<PlayerId>,
122
123    // Extra turns queue — players who get extra turns (issue #22, AddTurn effect).
124    // After cleanup, the game pops from here instead of advancing to the next player.
125    #[serde(skip)]
126    pub extra_turns: VecDeque<ExtraTurn>,
127
128    // Fog — prevent all combat damage this turn (issue #22, Fog effect).
129    // Reset at end of turn cleanup.
130    pub prevent_all_combat_damage: bool,
131
132    // Monarch designation (issue #22, BecomeMonarch effect).
133    pub monarch: Option<PlayerId>,
134
135    // Initiative holder (issue #22, TakeInitiative effect).
136    pub initiative_holder: Option<PlayerId>,
137
138    // End turn requested — skip remaining phases, jump to cleanup (issue #22, EndTurn effect).
139    pub end_turn_requested: bool,
140
141    // End combat requested — skip remaining combat steps (issue #22, EndCombatPhase effect).
142    pub end_combat_requested: bool,
143
144    // Extra combat phases to insert after current combat (issue #22, AddPhase effect).
145    pub extra_combat_phases: u32,
146
147    // Next card ID counter
148    next_card_id: u32,
149
150    /// Monotonically increasing counter for zone-entry timestamps.
151    /// Each time a card enters a zone, it gets the next value.
152    /// Used to order same-player triggers by zone entry order,
153    /// matching Java's `Zone.cardList` insertion order.
154    next_zone_timestamp: u64,
155    /// Monotonically increasing effect timestamp used by continuous/perpetual
156    /// effect records (Java parity: `game.getNextTimestamp()`).
157    next_effect_timestamp: i64,
158    /// Shared damage aggregation map for Java-style `DamageMap` flows.
159    /// Used across sub-ability chains and consumed by `DamageResolve`.
160    #[serde(skip)]
161    pub pending_damage_map: Option<CardDamageMap>,
162    /// Shared prevention map paired with `pending_damage_map`.
163    #[serde(skip)]
164    pub pending_prevent_map: Option<CardDamageMap>,
165    /// Shared zone-change aggregation table for Java-style `ChangeZoneTable` flows.
166    /// Used across sub-ability chains and consumed by `ChangeZoneResolve`.
167    #[serde(skip)]
168    pub pending_change_zone_table: Option<CardZoneTable>,
169
170    /// Token scripts that have already consumed game-RNG for art selection.
171    /// Java's `TokenDb` caches prototypes globally, consuming RNG only on
172    /// first creation. Subsequent creations of the same token type reuse the
173    /// cached prototype without RNG. This set mirrors that behavior.
174    #[serde(skip)]
175    pub synced_token_scripts: std::collections::BTreeSet<String>,
176
177    /// Periodic LKI snapshot of battlefield cards.
178    /// Mirrors Java's `Game.lastStateBattlefield`.
179    /// Updated by `copy_last_state()` at key game checkpoints.
180    #[serde(skip)]
181    pub last_state_battlefield: Vec<crate::lki::CardSnapshot>,
182
183    /// Snapshot of cards on the battlefield at the start of the current SBA check.
184    /// Used by `DisableTriggers` (Hushbringer) to check LKI — a creature that dies
185    /// in the same batch as another creature still suppresses the other's death trigger.
186    /// Mirrors Java's `LastStateBattlefield` passed through `RunParams`.
187    /// Set at the start of `check_state_based_actions_with_triggers`, cleared after.
188    #[serde(skip)]
189    pub pre_sba_battlefield: Vec<CardId>,
190
191    /// Last card sacrificed as a cost (for `Sacrificed$CardPower` SVar resolution).
192    /// Mirrors Java's `sa.getPaidList("SacrificedCards")`.
193    #[serde(skip)]
194    pub last_sacrificed_card: Option<CardId>,
195}
196
197impl GameState {
198    pub fn new(player_names: &[&str], starting_life: i32) -> Self {
199        let mut players = Vec::new();
200        let mut player_order = Vec::new();
201
202        for (i, name) in player_names.iter().enumerate() {
203            let pid = PlayerId(i as u32);
204            players.push(PlayerState::new(pid, name.to_string(), starting_life));
205            player_order.push(pid);
206        }
207
208        let zones = ZoneStore::new(&player_order);
209
210        GameState {
211            cards: Vec::new(),
212            players,
213            zones,
214            stack: MagicStack::new(),
215            cost_payment_stack: CostPaymentStack::new(),
216            is_night: false,
217            day_night_started: false,
218            turn: TurnState::new(player_order[0], player_order.len() as u32),
219            player_order,
220            game_over: false,
221            winner: None,
222            extra_turns: VecDeque::new(),
223            prevent_all_combat_damage: false,
224            monarch: None,
225            initiative_holder: None,
226            end_turn_requested: false,
227            end_combat_requested: false,
228            extra_combat_phases: 0,
229            next_card_id: 0,
230            next_zone_timestamp: 0,
231            next_effect_timestamp: 1,
232            pending_damage_map: None,
233            pending_prevent_map: None,
234            pending_change_zone_table: None,
235            synced_token_scripts: std::collections::BTreeSet::new(),
236            last_state_battlefield: Vec::new(),
237            pre_sba_battlefield: Vec::new(),
238            last_sacrificed_card: None,
239        }
240    }
241
242    /// Create a new card instance and return its ID. Does NOT place it in a zone.
243    pub fn create_card(&mut self, mut card: Card) -> CardId {
244        let id = CardId(self.next_card_id);
245        self.next_card_id += 1;
246        card.id = id;
247        let bound_host = card.clone();
248        for trigger in &mut card.triggers {
249            trigger.bind_host_card_id(bound_host.id);
250        }
251        for static_ability in &mut card.static_abilities {
252            static_ability.base.set_host_card_id(bound_host.id);
253        }
254        for replacement_effect in &mut card.replacement_effects {
255            replacement_effect.base.set_host_card_id(bound_host.id);
256        }
257        self.cards.push(card);
258        id
259    }
260
261    // --- Accessors ---
262
263    pub fn card(&self, id: CardId) -> &Card {
264        &self.cards[id.index()]
265    }
266
267    pub fn card_mut(&mut self, id: CardId) -> &mut Card {
268        &mut self.cards[id.index()]
269    }
270
271    pub fn player(&self, id: PlayerId) -> &PlayerState {
272        &self.players[id.index()]
273    }
274
275    pub fn player_mut(&mut self, id: PlayerId) -> &mut PlayerState {
276        &mut self.players[id.index()]
277    }
278
279    pub fn zone(&self, zone_type: ZoneType, owner: PlayerId) -> &Zone {
280        self.zones.get(zone_type, owner).expect("Zone not found")
281    }
282
283    pub fn zone_mut(&mut self, zone_type: ZoneType, owner: PlayerId) -> &mut Zone {
284        self.zones
285            .get_mut(zone_type, owner)
286            .expect("Zone not found")
287    }
288
289    pub fn zone_store_snapshot(&self) -> ZoneStore {
290        self.zones.clone()
291    }
292
293    pub fn replace_zone_store(&mut self, zones: ZoneStore) {
294        self.zones = zones;
295    }
296
297    pub fn iter_zones(&self) -> impl Iterator<Item = (ZoneKey, &Zone)> {
298        self.zones.iter()
299    }
300
301    pub fn cards_in_all_zones(&self, zone_type: ZoneType) -> impl Iterator<Item = CardId> + '_ {
302        self.iter_zones()
303            .filter(move |(key, _)| key.zone_type == zone_type)
304            .flat_map(|(_, zone)| zone.cards.iter().copied())
305    }
306
307    pub fn card_zone_location(&self, card: CardId) -> Option<ZoneKey> {
308        self.zones.card_location(card)
309    }
310
311    pub fn card_zone(&self, card: CardId) -> Option<ZoneType> {
312        self.card_zone_location(card)
313            .map(|location| location.zone_type)
314    }
315
316    pub fn card_current_zone(&self, card: CardId) -> ZoneType {
317        self.card_zone(card).unwrap_or_else(|| self.card(card).zone)
318    }
319
320    pub fn card_is_in_zone(&self, card: CardId, zone: ZoneType) -> bool {
321        self.card_current_zone(card) == zone
322    }
323
324    pub fn card_zone_owner(&self, card: CardId) -> Option<PlayerId> {
325        self.card_zone_location(card).map(|location| location.owner)
326    }
327
328    pub fn card_zone_location_matches_card(&self, card: CardId) -> bool {
329        let card_ref = self.card(card);
330        match self.card_zone_location(card) {
331            Some(location) => {
332                location.zone_type == card_ref.zone && location.owner == card_ref.controller
333            }
334            None => card_ref.zone == ZoneType::None,
335        }
336    }
337
338    pub fn reset_zone_turn_tracking(&mut self) {
339        for zone in self.zones.values_mut() {
340            zone.reset_cards_added_this_turn();
341        }
342    }
343
344    pub fn reset_card_turn_tracking(&mut self) {
345        for card in &mut self.cards {
346            card.reset_activations_per_turn();
347            card.reset_ability_resolved_this_turn();
348        }
349    }
350
351    pub(crate) fn remove_card_from_zone(
352        &mut self,
353        zone_type: ZoneType,
354        owner: PlayerId,
355        card: CardId,
356    ) -> bool {
357        if std::env::var("FORGE_ZONE_TRACE").is_ok()
358            && self.cards[card.index()].card_name == "Mind Stone"
359        {
360            eprintln!(
361                "[zone-rust] T{} remove {:?} {} from {:?} owner={:?}",
362                self.turn.turn_number,
363                card,
364                self.cards[card.index()].card_name,
365                zone_type,
366                owner
367            );
368        }
369        self.zones.remove_card(zone_type, owner, card)
370    }
371
372    pub(crate) fn add_card_to_zone(&mut self, zone_type: ZoneType, owner: PlayerId, card: CardId) {
373        if std::env::var("FORGE_ZONE_TRACE").is_ok()
374            && self.cards[card.index()].card_name == "Mind Stone"
375        {
376            eprintln!(
377                "[zone-rust] T{} add {:?} {} -> {:?} owner={:?}",
378                self.turn.turn_number,
379                card,
380                self.cards[card.index()].card_name,
381                zone_type,
382                owner
383            );
384        }
385        self.zones.add_card_to_top(zone_type, owner, card);
386    }
387
388    pub(crate) fn add_card_to_zone_bottom(
389        &mut self,
390        zone_type: ZoneType,
391        owner: PlayerId,
392        card: CardId,
393    ) {
394        self.zones.add_card_to_bottom(zone_type, owner, card);
395    }
396
397    pub fn take_top_card_from_zone(
398        &mut self,
399        zone_type: ZoneType,
400        owner: PlayerId,
401    ) -> Option<CardId> {
402        self.zones.take_top_card(zone_type, owner)
403    }
404
405    pub fn take_top_cards_from_zone(
406        &mut self,
407        zone_type: ZoneType,
408        owner: PlayerId,
409        count: usize,
410    ) -> Vec<CardId> {
411        let mut cards = Vec::with_capacity(count);
412        for _ in 0..count {
413            let Some(card) = self.take_top_card_from_zone(zone_type, owner) else {
414                break;
415            };
416            cards.push(card);
417        }
418        cards.reverse();
419        cards
420    }
421
422    pub fn reorder_card_in_zone(
423        &mut self,
424        zone_type: ZoneType,
425        owner: PlayerId,
426        card: CardId,
427        index: usize,
428    ) {
429        self.zones.reorder_card(zone_type, owner, card, index);
430    }
431
432    pub fn move_cards_to_zone_top(
433        &mut self,
434        zone_type: ZoneType,
435        owner: PlayerId,
436        cards: &[CardId],
437    ) {
438        self.zones.move_cards_to_top(zone_type, owner, cards);
439    }
440
441    pub fn move_cards_to_zone_bottom(
442        &mut self,
443        zone_type: ZoneType,
444        owner: PlayerId,
445        cards: &[CardId],
446    ) {
447        self.zones.move_cards_to_bottom(zone_type, owner, cards);
448    }
449
450    pub fn replace_zone_cards(&mut self, zone_type: ZoneType, owner: PlayerId, cards: Vec<CardId>) {
451        self.zones.replace_cards(zone_type, owner, cards);
452    }
453
454    pub fn shuffle_zone_cards(
455        &mut self,
456        zone_type: ZoneType,
457        owner: PlayerId,
458        rng: &mut dyn crate::game_rng::GameRng,
459    ) {
460        self.zones.shuffle_cards(zone_type, owner, rng);
461    }
462
463    pub fn shuffle_zone_cards_with_rand<R: rand::Rng + ?Sized>(
464        &mut self,
465        zone_type: ZoneType,
466        owner: PlayerId,
467        rng: &mut R,
468    ) {
469        self.zones.shuffle_cards_with_rand(zone_type, owner, rng);
470    }
471
472    pub(crate) fn save_zone_lki(
473        &mut self,
474        zone_type: ZoneType,
475        owner: PlayerId,
476        card: CardId,
477        from: ZoneType,
478    ) {
479        self.zones.save_lki(zone_type, owner, card, from);
480    }
481
482    pub fn active_player(&self) -> PlayerId {
483        self.turn.active_player
484    }
485
486    pub fn is_day(&self) -> bool {
487        self.day_night_started && !self.is_night
488    }
489
490    pub fn is_neither_day_nor_night(&self) -> bool {
491        !self.day_night_started
492    }
493
494    pub fn next_player(&self, player: PlayerId) -> PlayerId {
495        let current_idx = self
496            .player_order
497            .iter()
498            .position(|&p| p == player)
499            .unwrap_or(0);
500        for i in 1..self.player_order.len() {
501            let next_idx = (current_idx + i) % self.player_order.len();
502            let next_pid = self.player_order[next_idx];
503            if self.player(next_pid).is_alive() {
504                return next_pid;
505            }
506        }
507        player
508    }
509
510    /// Return the turn number of `player`'s most recent combat phase, if
511    /// known. Used by `Charm$ ChoiceRestriction$ YourLastCombat`.
512    ///
513    /// The Rust engine doesn't yet persist last-combat timestamps per player,
514    /// so this is a best-effort: it returns the current turn number iff that
515    /// turn's active player is `player` and we're past the combat phase.
516    /// Cards that rely on cross-turn last-combat tracking will treat the
517    /// restriction as always satisfied (safer than never).
518    pub fn last_combat_turn_of(&self, player: PlayerId) -> Option<i32> {
519        if self.turn.active_player == player {
520            Some(self.turn.turn_number as i32)
521        } else {
522            None
523        }
524    }
525
526    pub fn opponent_of(&self, player: PlayerId) -> PlayerId {
527        for &pid in &self.player_order {
528            if pid != player && self.player(pid).is_alive() {
529                return pid;
530            }
531        }
532        player // no opponent found (shouldn't happen in normal games)
533    }
534
535    pub fn alive_players(&self) -> Vec<PlayerId> {
536        self.player_order
537            .iter()
538            .filter(|&&pid| self.player(pid).is_alive())
539            .copied()
540            .collect()
541    }
542
543    /// Get all cards in a specific zone for a player.
544    pub fn cards_in_zone(&self, zone_type: ZoneType, owner: PlayerId) -> &[CardId] {
545        &self.zone(zone_type, owner).cards
546    }
547
548    /// Get all creatures on the battlefield for a player.
549    pub fn creatures_on_battlefield(&self, player: PlayerId) -> Vec<CardId> {
550        self.cards_in_zone(ZoneType::Battlefield, player)
551            .iter()
552            .filter(|&&cid| self.card(cid).is_creature())
553            .copied()
554            .collect()
555    }
556
557    /// Assign the next zone timestamp to a card, returning the value.
558    /// Called whenever a card enters a new zone to track insertion order.
559    pub fn assign_zone_timestamp(&mut self, card_id: CardId) -> u64 {
560        let ts = self.next_zone_timestamp;
561        self.next_zone_timestamp += 1;
562        self.cards[card_id.index()].zone_timestamp = ts;
563        ts
564    }
565
566    /// Return the next monotonic effect timestamp.
567    pub fn next_effect_timestamp(&mut self) -> i64 {
568        let ts = self.next_effect_timestamp;
569        self.next_effect_timestamp = self.next_effect_timestamp.saturating_add(1);
570        ts
571    }
572
573    /// Ensure shared damage/prevent maps exist for this resolution scope.
574    pub fn ensure_pending_damage_maps(&mut self) {
575        if self.pending_damage_map.is_none() {
576            self.pending_damage_map = Some(CardDamageMap::default());
577        }
578        if self.pending_prevent_map.is_none() {
579            self.pending_prevent_map = Some(CardDamageMap::default());
580        }
581    }
582
583    /// Clear shared damage/prevent maps.
584    pub fn clear_pending_damage_maps(&mut self) {
585        self.pending_damage_map = None;
586        self.pending_prevent_map = None;
587    }
588
589    /// Ensure a shared zone-change table exists for this resolution scope.
590    pub fn ensure_pending_change_zone_table(&mut self) {
591        if self.pending_change_zone_table.is_none() {
592            self.pending_change_zone_table = Some(CardZoneTable::default());
593        }
594    }
595
596    /// Clear the shared zone-change table.
597    pub fn clear_pending_change_zone_table(&mut self) {
598        self.pending_change_zone_table = None;
599    }
600
601    /// Get all lands on the battlefield for a player.
602    pub fn lands_on_battlefield(&self, player: PlayerId) -> Vec<CardId> {
603        self.cards_in_zone(ZoneType::Battlefield, player)
604            .iter()
605            .filter(|&&cid| self.card(cid).is_land())
606            .copied()
607            .collect()
608    }
609}
610
611#[cfg(test)]
612mod tests {
613    use super::*;
614    use forge_foundation::{CardTypeLine, ColorSet, ManaCost};
615
616    #[test]
617    fn create_game() {
618        let game = GameState::new(&["Alice", "Bob"], 20);
619        assert_eq!(game.players.len(), 2);
620        assert_eq!(game.player(PlayerId(0)).name, "Alice");
621        assert_eq!(game.player(PlayerId(1)).name, "Bob");
622        assert_eq!(game.player(PlayerId(0)).life, 20);
623        assert!(game.zone(ZoneType::Sideboard, PlayerId(0)).is_empty());
624        assert!(game.zone(ZoneType::AttractionDeck, PlayerId(0)).is_empty());
625        assert!(game.zone(ZoneType::ContraptionDeck, PlayerId(0)).is_empty());
626    }
627
628    #[test]
629    fn create_card_and_zone() {
630        let mut game = GameState::new(&["Alice", "Bob"], 20);
631        let card = Card::new(
632            CardId(0),
633            "Grizzly Bears".to_string(),
634            PlayerId(0),
635            CardTypeLine::parse("Creature Bear"),
636            ManaCost::parse("1 G"),
637            ColorSet::GREEN,
638            Some(2),
639            Some(2),
640            vec![],
641            vec![],
642        );
643        let cid = game.create_card(card);
644        game.add_card_to_zone(ZoneType::Library, PlayerId(0), cid);
645        game.card_mut(cid).zone = ZoneType::Library;
646        assert_eq!(game.zone(ZoneType::Library, PlayerId(0)).len(), 1);
647        assert_eq!(game.card_zone(cid), Some(ZoneType::Library));
648    }
649
650    #[test]
651    fn opponent_lookup() {
652        let game = GameState::new(&["Alice", "Bob"], 20);
653        assert_eq!(game.opponent_of(PlayerId(0)), PlayerId(1));
654        assert_eq!(game.opponent_of(PlayerId(1)), PlayerId(0));
655    }
656
657    #[test]
658    fn lki_snapshot_captures_battlefield_state() {
659        let mut game = GameState::new(&["Alice", "Bob"], 20);
660
661        // Create a 3/3 creature on the battlefield
662        let mut card = Card::new(
663            CardId(0),
664            "Grizzly Bears".to_string(),
665            PlayerId(0),
666            CardTypeLine::parse("Creature Bear"),
667            ManaCost::parse("1 G"),
668            ColorSet::GREEN,
669            Some(3),
670            Some(3),
671            vec![],
672            vec![],
673        );
674        card.zone = ZoneType::Battlefield;
675        let cid = game.create_card(card);
676
677        // Take LKI snapshot
678        game.copy_last_state();
679
680        // Verify snapshot captured the correct power/toughness
681        let snapshot = game.get_lki_snapshot(cid).expect("snapshot should exist");
682        assert_eq!(snapshot.power, 3);
683        assert_eq!(snapshot.toughness, 3);
684        assert_eq!(snapshot.card_name, "Grizzly Bears");
685
686        // Move card to graveyard and verify snapshot still exists
687        game.card_mut(cid).zone = ZoneType::Graveyard;
688        let snapshot = game
689            .get_lki_snapshot(cid)
690            .expect("snapshot should still exist");
691        assert_eq!(snapshot.power, 3);
692
693        // Snapshot preserves stale entries for LKI (cards that left the battlefield).
694        // This matches Java's behavior where LKI persists through resolution chains.
695        game.copy_last_state();
696        assert!(
697            game.get_lki_snapshot(cid).is_some(),
698            "stale LKI should persist"
699        );
700    }
701}