Skip to main content

manabrew_engine/
game.rs

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