1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
use forge_foundation::ZoneType;
use crate::card::{valid_filter, Card};
use crate::cost::{parse_cost, Cost};
use crate::game::GameState;
use crate::ids::PlayerId;
use crate::spellability::{matches_valid_sa, SpellAbility};
use crate::staticability::StaticMode;
/// Descriptor for an alternative cost produced by a static ability.
#[derive(Debug, Clone)]
pub struct AlternativeCostEntry {
/// The parsed alternative cost (Cost$ param with ConvertedManaCost replaced).
pub cost: Cost,
/// The raw cost template string (after ConvertedManaCost substitution).
pub cost_string: String,
/// Whether the original SA is an ability (not a spell).
pub is_ability: bool,
/// Whether the original SA is a spell.
pub is_spell: bool,
/// Propagated XAlternative param, if any.
pub x_alternative: Option<String>,
/// Propagated Announce param, if any.
pub announce: Option<String>,
/// Propagated ManaRestriction param, if any.
pub mana_restriction: Option<String>,
/// Zone restriction from the static ability's ActiveZones (single zone case).
pub zone_restriction: Option<ZoneType>,
/// StackDescription override, if any.
pub stack_description: Option<String>,
/// CostDesc override, if any.
pub cost_desc: Option<String>,
/// Description text for the alternative cost.
pub description: String,
/// Named override — custom card alternative cost naming.
pub named: Option<String>,
/// Whether this is an AltCost from the source card itself (adds OptionalCost.AltCost).
pub is_own_alt_cost: bool,
}
/// Collect all alternative costs that apply to `sa` cast by `player` from `source`.
pub fn alternative_costs(
game: &GameState,
cards: &[Card],
sa: &SpellAbility,
source: &Card,
player: PlayerId,
) -> Vec<AlternativeCostEntry> {
let mut result = Vec::new();
let source_id = source.id;
// Process source card first
collect_from_card(game, source, sa, source, player, &mut result);
// Process all cards in static ability source zones
for ca in cards
.iter()
.filter(|c| c.zone.is_static_ability_source() && c.id != source_id)
{
collect_from_card(game, ca, sa, source, player, &mut result);
}
result
}
/// Check if any alternative cost static applies to this card for this player.
/// Convenience wrapper around `alternative_costs` — returns true if at least one
/// alternative cost is available.
pub fn has_alternative_cost(
game: &GameState,
cards: &[Card],
sa: &SpellAbility,
source: &Card,
player: PlayerId,
) -> bool {
let source_id = source.id;
// Check source card first
for st_ab in source
.static_abilities
.iter()
.filter(|s| s.is_active_for(StaticMode::AlternativeCost, source.zone))
{
if !st_ab.check_conditions(source, game) {
continue;
}
if apply(st_ab, sa, source, source, player) {
return true;
}
}
// Check all cards in static ability source zones
for ca in cards
.iter()
.filter(|c| c.zone.is_static_ability_source() && c.id != source_id)
{
for st_ab in ca
.static_abilities
.iter()
.filter(|s| s.is_active_for(StaticMode::AlternativeCost, ca.zone))
{
if !st_ab.check_conditions(ca, game) {
continue;
}
if apply(st_ab, sa, source, ca, player) {
return true;
}
}
}
false
}
/// Process all static abilities on a single card, collecting matching
/// alternative cost entries.
fn collect_from_card(
game: &GameState,
ca: &Card,
sa: &SpellAbility,
source: &Card,
player: PlayerId,
result: &mut Vec<AlternativeCostEntry>,
) {
for st_ab in ca
.static_abilities
.iter()
.filter(|s| s.is_active_for(StaticMode::AlternativeCost, ca.zone))
{
if !st_ab.check_conditions(ca, game) {
continue;
}
if !apply(st_ab, sa, source, ca, player) {
continue;
}
// Parse and substitute Cost$ param
let Some(cost_template_raw) = st_ab.ir.cost.as_deref() else {
continue;
};
// Replace "ConvertedManaCost" with the source card's CMC
let cmc = source.mana_value();
let cost_template = cost_template_raw.replace("ConvertedManaCost", &cmc.to_string());
let is_ability = sa.is_activated;
let is_spell = sa.is_spell;
let cost = parse_cost(&cost_template);
// Propagate XAlternative param
let x_alternative = st_ab.ir.x_alternative_text.clone();
// Propagate Announce param
let announce = st_ab.ir.announce_text.clone();
// Propagate ManaRestriction param
let mana_restriction = st_ab.ir.mana_restriction_text.clone();
// Zone restriction from ActiveZones — only if host card is not immutable
// (Rust Card has no is_immutable field yet, so we skip that guard)
// TODO: Add is_immutable check when Card gains that field
let zone_restriction = match st_ab.ir.active_zones.as_slice() {
[zone] => Some(*zone),
_ => None,
};
// StackDescription override
let stack_description = st_ab.ir.stack_description_text.clone();
let mut desc = String::new();
let cost_desc = if is_ability {
// CostDesc for abilities
let cd = if let Some(cd_param) = st_ab.ir.cost_desc_text.as_deref() {
// TODO: implement ManaCostParser.parse() equivalent
cd_param.to_string()
} else {
cost_template.clone()
};
desc.push_str(&cd);
Some(cd)
} else {
None
};
if is_spell {
// Append the original spell description
// TODO: sa.getDescription() equivalent — use ability_text as fallback
desc.push_str(&sa.ability_text);
// Check if source card is the host card of the static ability
if source.id == ca.id {
// Same card — use the Description param
if let Some(alt_desc) = st_ab.ir.description_text.as_deref() {
desc.push_str(" (");
desc.push_str(alt_desc);
desc.push_str(") ");
}
} else {
// Different card — generic "by paying X instead of its mana cost"
desc.push_str(" (by paying ");
desc.push_str(&cost_template);
desc.push_str(" instead of its mana cost)");
}
}
// Whether this is the card's own alt cost (same host card)
let is_own_alt_cost = is_spell && source.id == ca.id;
// Named param override for custom cards
let named = st_ab.ir.named_text.clone();
result.push(AlternativeCostEntry {
cost,
cost_string: cost_template,
is_ability,
is_spell,
x_alternative,
announce,
mana_restriction,
zone_restriction,
stack_description,
cost_desc,
description: desc,
named,
is_own_alt_cost,
});
}
}
fn apply(
st_ab: &crate::staticability::StaticAbility,
sa: &SpellAbility,
source: &Card,
host: &Card,
player: PlayerId,
) -> bool {
if let Some(valid_sa) = st_ab.ir.valid_sa.as_deref() {
if !matches_valid_sa(valid_sa, sa, host, Some(source)) {
return false;
}
}
if !valid_filter::matches_valid_card_selector_opt(st_ab.ir.valid_card.as_ref(), source, host) {
return false;
}
if !valid_filter::matches_valid_player_selector_opt(
st_ab.ir.valid_player.as_ref(),
player,
host.controller,
) {
return false;
}
true
}
/// Apply an `AlternativeCostEntry` to a `SpellAbility`, mutating it in place
pub fn apply_alternative_cost_to_sa(sa: &mut SpellAbility, entry: &AlternativeCostEntry) {
// Replace the pay costs with the alternative cost
sa.pay_costs = Some(entry.cost.clone());
// Mark as not a basic spell
// TODO: sa.setBasicSpell(false) — no field yet
if let Some(ref announce) = entry.announce {
sa.ir.announce_text = Some(announce.clone());
}
if let Some(ref stack_desc) = entry.stack_description {
sa.ir.stack_description_text = Some(stack_desc.clone());
}
if let Some(ref cost_desc) = entry.cost_desc {
sa.ir.precost_desc = Some(cost_desc.clone());
}
// Apply Named override
if let Some(ref named) = entry.named {
sa.ir.name_text = Some(named.clone());
}
let _ = &entry.x_alternative;
let _ = &entry.mana_restriction;
// TODO: Zone restriction on sa.restrictions when SpellAbilityRestriction is ported
// TODO: addOptionalCost(OptionalCost.AltCost) for is_own_alt_cost
}