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}