Skip to main content

manabrew_engine/
lki.rs

1//! Last-Known Information (LKI) system.
2//!
3//! Mirrors Java's `Game.copyLastState()` + `Game.lastStateBattlefield`.
4//!
5//! ## Overview
6//!
7//! The LKI system maintains two types of snapshots to resolve trigger SVars
8//! (e.g. `TriggeredCard$CardPower`) using a card's state at the time it was
9//! last on the battlefield:
10//!
11//! 1. **Periodic snapshots** (`GameState.last_state_battlefield`):
12//!    - Captured by `copy_last_state()` at key checkpoints (phase transitions,
13//!      before SBAs, before combat)
14//!    - Lightweight `CardSnapshot` structs for all battlefield cards
15//!    - Stale snapshots persist after cards leave (for LKI lookups)
16//!
17//! 2. **Per-card LKI** (`Card.lki_power` / `lki_toughness`):
18//!    - Saved in `action.rs::change_zone()` when a card leaves the battlefield
19//!    - Captures the exact power/toughness at zone-change time
20//!    - Primary source for trigger SVars
21//!
22//! ## Resolution hierarchy
23//!
24//! When resolving `TriggeredCard$CardPower` / `TriggeredCard$CardToughness`:
25//! 1. Check `card.lki_power` / `card.lki_toughness` (captured at zone change)
26//! 2. If zero, check periodic snapshot (in case card entered after last checkpoint)
27//! 3. Fall back to zero if neither source has data
28//!
29//! ## Lifecycle
30//!
31//! ```text
32//! Card enters battlefield
33//!   → assign zone timestamp
34//!   → update_lki_snapshot() creates/updates periodic snapshot
35//!
36//! Phase transition / SBA check
37//!   → copy_last_state() refreshes all battlefield snapshots
38//!
39//! Card leaves battlefield (dies, exiled, etc.)
40//!   → action.rs captures lki_power/lki_toughness
41//!   → periodic snapshot remains (stale but valid for LKI)
42//!
43//! Trigger fires (e.g. "When ~ dies, deal damage equal to its power")
44//!   → resolve_lki_power() checks lki_power, then periodic snapshot
45//!   → SpellAbility resolves with correct LKI value
46//! ```
47//!
48//! ## Java equivalents
49//!
50//! - `Game.copyLastState()` → `GameState::copy_last_state()`
51//! - `Game.lastStateBattlefield` → `GameState.last_state_battlefield`
52//! - `Card.getPower()` (during trigger) → `resolve_lki_power()`
53
54use crate::card::{Card, CounterType};
55use crate::ids::{CardId, PlayerId};
56use crate::spellability::SpellAbility;
57use forge_foundation::ZoneType;
58use std::collections::BTreeMap;
59
60/// Lightweight snapshot of a card's state on the battlefield.
61/// Captured by `GameState::copy_last_state()` at key checkpoints.
62#[derive(Debug, Clone)]
63pub struct CardSnapshot {
64    pub id: CardId,
65    pub controller: PlayerId,
66    pub owner: PlayerId,
67    pub power: i32,
68    pub toughness: i32,
69    pub counters: BTreeMap<CounterType, i32>,
70    pub tapped: bool,
71    pub zone: ZoneType,
72    pub card_name: String,
73}
74
75impl CardSnapshot {
76    /// Create a snapshot from a live card.
77    pub fn from_card(card: &Card) -> Self {
78        Self {
79            id: card.id,
80            controller: card.controller,
81            owner: card.owner,
82            power: card.power(),
83            toughness: card.toughness(),
84            counters: card.counters.clone(),
85            tapped: card.tapped,
86            zone: card.zone,
87            card_name: card.card_name.clone(),
88        }
89    }
90}
91
92/// LKI methods for `GameState`.
93/// Implemented in this module to keep all LKI logic centralized.
94impl crate::game::GameState {
95    /// Snapshot all battlefield cards for LKI.
96    /// Mirrors Java's `Game.copyLastState()`.
97    /// Called at phase transitions, before SBAs, before combat.
98    pub fn copy_last_state(&mut self) {
99        // Update existing snapshots for cards still on the battlefield.
100        // Add new snapshots for cards that entered since last checkpoint.
101        // Keep stale snapshots for cards that left — they serve as LKI
102        // for trigger SVars like TriggeredCard$CardPower.
103        // This matches Java's behavior where lastStateBattlefield is only
104        // fully cleared at major checkpoints but individual entries persist
105        // through resolution chains.
106        for card in self.cards.iter() {
107            if card.zone == ZoneType::Battlefield {
108                if let Some(existing) = self
109                    .last_state_battlefield
110                    .iter_mut()
111                    .find(|s| s.id == card.id)
112                {
113                    *existing = CardSnapshot::from_card(card);
114                } else {
115                    self.last_state_battlefield
116                        .push(CardSnapshot::from_card(card));
117                }
118            }
119        }
120    }
121
122    /// Look up a card's LKI snapshot from the last battlefield state.
123    /// Returns None if the card wasn't on the battlefield at the last checkpoint.
124    pub fn get_lki_snapshot(&self, card_id: CardId) -> Option<&CardSnapshot> {
125        self.last_state_battlefield.iter().find(|s| s.id == card_id)
126    }
127
128    /// Update the LKI snapshot for a specific card on the battlefield.
129    /// If the card is already in the snapshot, update it. Otherwise, add it.
130    /// Called when a card enters the battlefield or its state changes significantly.
131    /// Mirrors Java's incremental LKI updates between full copyLastState() calls.
132    pub fn update_lki_snapshot(&mut self, card_id: CardId) {
133        let card = &self.cards[card_id.index()];
134        if card.zone != ZoneType::Battlefield {
135            return;
136        }
137        let snapshot = CardSnapshot::from_card(card);
138        if let Some(existing) = self
139            .last_state_battlefield
140            .iter_mut()
141            .find(|s| s.id == card_id)
142        {
143            *existing = snapshot;
144        } else {
145            self.last_state_battlefield.push(snapshot);
146        }
147    }
148}
149
150/// Resolve LKI power for a trigger source card.
151///
152/// Checks `card.lki_power` (captured at zone-change time) first, then falls
153/// back to the periodic snapshot if `lki_power` is zero and a snapshot exists
154/// with non-zero power.
155///
156/// This handles the edge case where a card dies/leaves after entering the
157/// battlefield but before the next `copy_last_state()` checkpoint — the
158/// per-card LKI captures the correct value at zone-change time.
159///
160/// Returns 0 if no LKI data exists.
161pub fn resolve_lki_power(game: &crate::game::GameState, trigger_src: CardId) -> i32 {
162    // Check per-card LKI captured at zone-change time (most accurate).
163    // Some(0) is a valid LKI value (e.g. creature with -1/-1 counters reducing power to 0).
164    if let Some(lki) = game.card(trigger_src).lki_power {
165        return lki;
166    }
167    // No per-card LKI — fall back to periodic snapshot.
168    if let Some(snapshot) = game.get_lki_snapshot(trigger_src) {
169        return snapshot.power;
170    }
171    0
172}
173
174/// Resolve LKI toughness for a trigger source card.
175///
176/// Checks `card.lki_toughness` (captured at zone-change time) first, then
177/// falls back to the periodic snapshot if `lki_toughness` is zero and a
178/// snapshot exists.
179///
180/// Returns 0 if no LKI data exists.
181pub fn resolve_lki_toughness(game: &crate::game::GameState, trigger_src: CardId) -> i32 {
182    // Check per-card LKI captured at zone-change time (most accurate).
183    if let Some(lki) = game.card(trigger_src).lki_toughness {
184        return lki;
185    }
186    // No per-card LKI — fall back to periodic snapshot.
187    if let Some(snapshot) = game.get_lki_snapshot(trigger_src) {
188        return snapshot.toughness;
189    }
190    0
191}
192
193/// Resolve LKI counter count for a trigger source card.
194///
195/// Used by death triggers that reference `TriggeredCard$CardCounters.TYPE`
196/// (e.g. Servant of the Scale, Modular).
197///
198/// Checks the per-card LKI counters first, then falls back to the periodic
199/// snapshot's counter map.
200///
201/// Returns 0 if no LKI data or no counters of the given type exist.
202pub fn resolve_lki_counter_count(
203    game: &crate::game::GameState,
204    trigger_src: CardId,
205    counter_type: &crate::card::CounterType,
206) -> i32 {
207    // Check per-card LKI counters captured at zone-change time.
208    let card = game.card(trigger_src);
209    if let Some(&count) = card.lki_counters.as_ref().and_then(|c| c.get(counter_type)) {
210        return count;
211    }
212    // Fall back to periodic snapshot.
213    if let Some(snapshot) = game.get_lki_snapshot(trigger_src) {
214        return snapshot.counters.get(counter_type).copied().unwrap_or(0);
215    }
216    0
217}
218
219fn trigger_card_object(sa: &SpellAbility, key: &str) -> Option<CardId> {
220    crate::ability::ability_key::from_string(key)
221        .and_then(|ability_key| sa.get_triggering_card(ability_key))
222}
223
224fn trigger_int_object(sa: &SpellAbility, key: &str) -> Option<i32> {
225    crate::ability::ability_key::from_string(key)
226        .and_then(|ability_key| sa.get_triggering_value(ability_key))
227        .and_then(|value| value.trim().parse::<i32>().ok())
228}
229
230/// Resolve SVar properties that explicitly ask for triggered-card LKI.
231///
232/// Handles the Forge patterns used by death/leaves triggers:
233/// `TriggeredCard$CardPower`, `TriggeredCard$CardToughness`, and
234/// `TriggeredCard$CardCounters.TYPE`.
235pub fn resolve_triggered_card_lki_svar(
236    game: &crate::game::GameState,
237    sa: &SpellAbility,
238    svar_expr: &str,
239) -> Option<i32> {
240    let property = svar_expr.strip_prefix("TriggeredCard$")?;
241    resolve_triggered_card_lki_property(game, sa, property)
242}
243
244pub fn resolve_triggered_card_lki_property(
245    game: &crate::game::GameState,
246    sa: &SpellAbility,
247    property: &str,
248) -> Option<i32> {
249    if property == "CardPower" {
250        if let Some(power) = trigger_int_object(sa, "TriggeredCardPower") {
251            return Some(power);
252        }
253        return trigger_card_object(sa, "Card")
254            .or(sa.trigger_source)
255            .map(|trigger_src| resolve_lki_power(game, trigger_src));
256    }
257
258    if property == "CardToughness" {
259        if let Some(toughness) = trigger_int_object(sa, "TriggeredCardToughness") {
260            return Some(toughness);
261        }
262        return trigger_card_object(sa, "Card")
263            .or(sa.trigger_source)
264            .map(|trigger_src| resolve_lki_toughness(game, trigger_src));
265    }
266
267    if let Some(counter_name) = property.strip_prefix("CardCounters.") {
268        let counter_type = crate::ability::effects::parse_counter_type(counter_name);
269        return trigger_card_object(sa, "Card")
270            .or(sa.trigger_source)
271            .map(|trigger_src| resolve_lki_counter_count(game, trigger_src, &counter_type));
272    }
273
274    if let Some(filter) = property.strip_prefix("Valid ") {
275        let card_id = trigger_card_object(sa, "Card").or(sa.trigger_source)?;
276        let source_id = sa.source?;
277        let selector = crate::parsing::cached_compiled_selector(filter);
278        let matched = crate::card::valid_filter::matches_valid_card_selector_with_context(
279            &selector,
280            game.card(card_id),
281            crate::card::valid_filter::MatchContext::from_source(game.card(source_id))
282                .with_game(game)
283                .with_spell_ability(sa),
284        );
285        return Some(i32::from(matched));
286    }
287
288    None
289}