Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions client/src/adapter/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2460,6 +2460,14 @@ export type GameEvent =
| { type: "LandPlayed"; data: { object_id: ObjectId; player_id: PlayerId; from_zone: Zone } }
| { type: "StackPushed"; data: { object_id: ObjectId } }
| { type: "StackResolved"; data: { object_id: ObjectId } }
// CR 714.2: a Saga's chapter ability finished resolving. Bookkeeping the
// engine publishes for meta-triggers (Narci, Fable Singer); non-visual, since
// the chapter ability's own effects already animate.
// `saga` is the engine's TriggerSourceContext for the exact Saga incarnation
// (CR 400.7). It is deliberately left unmodelled: this event is non-visual
// (see eventNormalizer) and the client never reads the payload, so declaring a
// partial shape here would assert a contract nothing checks.
| { type: "SagaChapterAbilityResolved"; data: { saga: unknown; controller: PlayerId; chapter: number; final_chapter: number } }
| { type: "Discarded"; data: { player_id: PlayerId; object_id: ObjectId } }
| { type: "EnduringStoryGained"; data: { player_id: PlayerId } }
| { type: "DamageCleared"; data: { object_id: ObjectId } }
Expand Down
3 changes: 3 additions & 0 deletions client/src/animation/eventNormalizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ const NON_VISUAL_EVENTS = new Set([
"PermanentUntapped",
"StackPushed",
"StackResolved",
// CR 714.2: same class as StackResolved — the chapter ability's own effects
// are what the player sees; this event exists for meta-triggers.
"SagaChapterAbilityResolved",
"ReplacementApplied",
"Regenerated",
"AttackersDeclared",
Expand Down
16 changes: 10 additions & 6 deletions crates/engine/src/ai_support/shortcut_efficacy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3371,21 +3371,25 @@ mod tests {
let body = body.split_once("\n}").expect("…and terminated").0;

// The names this enumeration cannot construct, PINNED so the gap cannot
// grow silently. Three carry payloads and have no bare-name spelling for
// grow silently. Four carry payloads and have no bare-name spelling for
// `FromStr`; the other three are PRE-EXISTING gaps in
// `types::triggers`'s own decoder — they are declared on the enum but have
// no `FromStr` arm, so `from_str` degrades them to `Unknown`. That gap is
// not this module's to fix (and `types/triggers.rs` is outside this
// change), but it IS this row's to disclose: none of the six is in the
// change), but it IS this row's to disclose: none of the seven is in the
// relieved list above, so the reverse containment below covers 165 of the
// enum's 171 variants and this constant names the remaining six.
// enum's 172 variants and this constant names the remaining seven.
// Sorted, and compared as a SET: this row's subject is which variants are
// undecodable, not where they sit in the declaration. Pinning declaration
// order would red this `ai_support` row on a no-op reordering of
// `TriggerMode` — a failure that says nothing about either module.
const UNCONSTRUCTIBLE: [&str; 6] = [
"Copied", // no `FromStr` arm
"Explored", // no `FromStr` arm
const UNCONSTRUCTIBLE: [&str; 7] = [
"Copied", // no `FromStr` arm
"Explored", // no `FromStr` arm
// Payload, and deliberately without a `FromStr` arm: Forge has no
// final-chapter meta-trigger type, so there is no Forge string to
// decode from. Inventing one would fabricate a mapping.
"FinalSagaChapterAbility", // payload
"HauntedCreatureDies", // no `FromStr` arm
"KeywordAbilityActivated", // payload
"Planeswalked", // payload
Expand Down
3 changes: 3 additions & 0 deletions crates/engine/src/analysis/ability_graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1185,6 +1185,9 @@ fn trigger_axis(trig: &TriggerDefinition) -> Option<AxisKey> {
| TriggerMode::RoomEntered
| TriggerMode::PlanarDice
| TriggerMode::Planeswalked { .. }
// CR 714.2e: a final-chapter meta-trigger consumes another permanent's
// chapter-ability lifecycle; no modeled producer axis.
| TriggerMode::FinalSagaChapterAbility { .. }
| TriggerMode::ChaosEnsues
| TriggerMode::RolledDie
| TriggerMode::RolledDieOnce
Expand Down
21 changes: 10 additions & 11 deletions crates/engine/src/database/synthesis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8994,16 +8994,12 @@ pub fn synthesize_read_ahead(face: &mut CardFace) {
if !face.keywords.contains(&Keyword::ReadAhead) {
return;
}
// CR 714.2d: final chapter number = greatest lore-counter threshold among
// this Saga's chapter triggers. No chapter abilities → nothing to read ahead to.
let Some(final_chapter) = face
.triggers
.iter()
.filter_map(|t| t.counter_filter.as_ref())
.filter(|f| f.counter_type == CounterType::Lore)
.filter_map(|f| f.threshold)
.max()
else {
// CR 714.2d: final chapter number = the greatest value among this Saga's
// chapter abilities, read from the chapter-symbol provenance the Saga parser
// records. Not inferred from lore thresholds: CR 714.2b gives a chapter
// symbol that shape, but a lore threshold trigger acquired some other way is
// not a chapter ability. No chapter abilities → nothing to read ahead to.
let Some(final_chapter) = face.triggers.iter().filter_map(|t| t.saga_chapter).max() else {
return;
};

Expand Down Expand Up @@ -22939,7 +22935,10 @@ mod devour_synthesis_tests {
.counter_filter(CounterTriggerFilter {
counter_type: CounterType::Lore,
threshold: Some(n),
}),
})
// CR 714.2: mirror what the Saga parser records — these
// fixtures stand in for real chapter symbols.
.saga_chapter(n),
);
}
face.replacements.push(
Expand Down
98 changes: 70 additions & 28 deletions crates/engine/src/game/game_object.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2911,24 +2911,66 @@ impl GameObject {
self.owner == player && self.zone == Zone::Graveyard && self.is_represented_by_a_card()
}

/// CR 714.1: Returns the final chapter number for a Saga, or None if not a Saga.
/// Derived at runtime from the maximum threshold in the trigger definitions' counter filters.
/// CR 714.2: Every chapter number this Saga's chapter abilities are keyed
/// to, read from the chapter-symbol provenance the Saga parser records
/// (`TriggerDefinition::saga_chapter`).
///
/// Deliberately NOT inferred from lore-counter thresholds. CR 714.2b gives a
/// chapter symbol the shape of a lore threshold trigger, but the converse
/// does not hold: a lore threshold trigger a Saga acquired some other way is
/// not a chapter ability, and counting it would corrupt the final chapter
/// number that CR 714.2d defines and CR 714.4's sacrifice depends on.
///
/// Empty for a non-Saga. Structural scan of the Saga's own triggers —
/// intrinsic to the card, not subject to functioning gates. `iter_all` is
/// pub(crate).
pub fn saga_chapter_numbers(&self) -> impl Iterator<Item = u32> + '_ {
self.card_types
.subtypes
.iter()
.any(|subtype| subtype == "Saga")
.then(|| self.trigger_definitions.iter_all())
.into_iter()
.flatten()
.filter_map(|entry| entry.definition.saga_chapter)
}

/// CR 714.2d: "A Saga's final chapter number is the greatest value among
/// chapter abilities it has." Returns `None` for a non-Saga.
///
/// CR 714.2d also assigns a final chapter number of 0 to a Saga with no
/// chapter abilities; this returns `None` there too, because every caller
/// uses `None` to mean "not a Saga to begin with" and CR 714.3c / CR 714.4
/// both exempt a Saga with no chapter abilities from the lore turn-based
/// action and the sacrifice.
pub fn final_chapter_number(&self) -> Option<u32> {
if !self.card_types.subtypes.iter().any(|s| s == "Saga") {
return None;
}
// Structural scan of this Saga's own triggers — intrinsic to the
// card, not subject to functioning gates. `iter_all` is pub(crate).
self.trigger_definitions
self.saga_chapter_numbers().max()
}

/// CR 714.2 + CR 714.2d: Identify one of this Saga's own chapter abilities
/// by the exact trigger occurrence that produced it, returning
/// `(chapter_number, final_chapter_number)`.
///
/// Keyed on the occurrence, so CR 714.2c's two chapter abilities printed on
/// one line stay distinguishable even though they share that line. The
/// chapter number comes from the recorded chapter symbol, never re-derived
/// from the lore count (wrong under Read Ahead, and wrong for a
/// multi-counter addition, which per CR 714.2b crosses several thresholds at
/// once) nor from the `"Chapter {n}"` description string.
///
/// Returns `None` for a non-Saga, or for an occurrence that is not one of
/// this permanent's chapter abilities.
pub fn saga_chapter_for_occurrence(
&self,
occurrence: &TriggerDefinitionOccurrenceRef,
) -> Option<(u32, u32)> {
let final_chapter = self.final_chapter_number()?;
let chapter = self
.trigger_definitions
.iter_all()
.filter_map(|entry| {
entry
.definition
.counter_filter
.as_ref()
.and_then(|f| f.threshold)
})
.max()
.find(|entry| &entry.occurrence == occurrence)
.and_then(|entry| entry.definition.saga_chapter)?;
Some((chapter, final_chapter))
}

/// CR 702.51a: Whether this object can be tapped for convoke mana.
Expand Down Expand Up @@ -3647,24 +3689,24 @@ mod tests {
);
obj.card_types.subtypes.push("Saga".to_string());
obj.trigger_definitions = vec![
TriggerDefinition::new(TriggerMode::CounterAdded).counter_filter(
CounterTriggerFilter {
TriggerDefinition::new(TriggerMode::CounterAdded)
.counter_filter(CounterTriggerFilter {
counter_type: CounterType::Lore,
threshold: Some(1),
},
),
TriggerDefinition::new(TriggerMode::CounterAdded).counter_filter(
CounterTriggerFilter {
})
.saga_chapter(1),
TriggerDefinition::new(TriggerMode::CounterAdded)
.counter_filter(CounterTriggerFilter {
counter_type: CounterType::Lore,
threshold: Some(2),
},
),
TriggerDefinition::new(TriggerMode::CounterAdded).counter_filter(
CounterTriggerFilter {
})
.saga_chapter(2),
TriggerDefinition::new(TriggerMode::CounterAdded)
.counter_filter(CounterTriggerFilter {
counter_type: CounterType::Lore,
threshold: Some(3),
},
),
})
.saga_chapter(3),
]
.into();
assert_eq!(obj.final_chapter_number(), Some(3));
Expand Down
17 changes: 17 additions & 0 deletions crates/engine/src/game/log.rs
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,10 @@ fn importance(event: &GameEvent) -> LogImportance {
| GameEvent::BecomesPlotted { .. }
| GameEvent::StackPushed { .. }
| GameEvent::StackResolved { .. }
// CR 714.2: bookkeeping the engine publishes so meta-triggers can
// observe a chapter ability finishing; the chapter's own effects carry
// the player-visible signal.
| GameEvent::SagaChapterAbilityResolved { .. }
| GameEvent::DamageCleared { .. }
| GameEvent::ResolutionHalted { .. }
| GameEvent::DamagePrevented { .. }
Expand Down Expand Up @@ -296,6 +300,9 @@ fn tone(event: &GameEvent) -> LogTone {
| GameEvent::LandPlayed { .. }
| GameEvent::StackPushed { .. }
| GameEvent::StackResolved { .. }
// CR 714.2: neither good nor bad news on its own — the drain or token
// the observing trigger produces is what carries tone.
| GameEvent::SagaChapterAbilityResolved { .. }
| GameEvent::Discarded { .. }
| GameEvent::Cycled { .. }
| GameEvent::DamageCleared { .. }
Expand Down Expand Up @@ -416,6 +423,10 @@ fn should_exclude_event(event: &GameEvent, state: &GameState) -> bool {
// StackPushed/StackResolved are low-signal bookkeeping —
// the meaningful info is in SpellCast/AbilityActivated and EffectResolved
GameEvent::StackPushed { .. } | GameEvent::StackResolved { .. } => true,
// CR 714.2: the chapter-resolution notification exists so meta-triggers
// can observe it; the player already saw the chapter ability itself
// resolve. Same low-signal bookkeeping class as StackResolved.
GameEvent::SagaChapterAbilityResolved { .. } => true,
_ => false,
}
}
Expand Down Expand Up @@ -491,6 +502,8 @@ fn categorize(event: &GameEvent) -> LogCategory {
| GameEvent::KeywordAbilityActivated { .. }
| GameEvent::StackPushed { .. }
| GameEvent::StackResolved { .. }
// CR 714.2: a chapter ability finishing resolution is a stack event.
| GameEvent::SagaChapterAbilityResolved { .. }
| GameEvent::SpellCountered { .. } => LogCategory::Stack,

GameEvent::AttackersDeclared { .. }
Expand Down Expand Up @@ -797,6 +810,10 @@ fn format_segments(event: &GameEvent, state: &GameState) -> Vec<LogSegment> {
vec![card_seg(state, *object_id), text(" resolves")]
}

// CR 714.2: filtered out by `is_low_signal` above — the chapter
// ability's own resolution line already told the player what happened.
GameEvent::SagaChapterAbilityResolved { .. } => vec![],

GameEvent::SpellCountered {
object_id,
countered_by,
Expand Down
3 changes: 3 additions & 0 deletions crates/engine/src/game/public_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,9 @@ pub fn mark_public_state_from_events(state: &mut GameState, events: &[GameEvent]
| GameEvent::LandPlayed { .. }
| GameEvent::StackPushed { .. }
| GameEvent::StackResolved { .. }
// CR 714.2: a notification consumed by triggers only; the chapter
// ability's own effects dirty whatever display state they touched.
| GameEvent::SagaChapterAbilityResolved { .. }
| GameEvent::GameOver { .. }
// CR 732.2: a halted-resolution notification dirties no display state.
| GameEvent::ResolutionHalted { .. }
Expand Down
26 changes: 26 additions & 0 deletions crates/engine/src/game/quantity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5813,6 +5813,23 @@ where
/// CR 202.3: Resolve an object's mana value through the same ObjectScope axis
/// used for power/toughness. Source scope falls back to LKI for objects that
/// moved during resolution; target scope reads the selected object target.
/// CR 400.7 + CR 202.3: The mana value an event supplies for its OWN pinned
/// subject incarnation, when the current trigger event carries one.
///
/// Only `SagaChapterAbilityResolved` pins a subject today. Its Saga is routinely
/// gone by the time an observer resolves — CR 714.4 sacrifices it the moment the
/// final chapter ability leaves the stack — and a re-entered Saga can occupy the
/// same storage id, so neither live state nor the id-keyed LKI cache can be
/// trusted to answer for the original.
fn event_source_mana_value_override(state: &GameState) -> Option<i32> {
match current_or_detection_trigger_event(state)? {
GameEvent::SagaChapterAbilityResolved { saga, .. } => {
Some(u32_to_i32_saturating(saga.lki.mana_value))
}
_ => None,
}
}

fn resolve_object_mana_value(
state: &GameState,
scope: ObjectScope,
Expand Down Expand Up @@ -5843,6 +5860,15 @@ fn resolve_object_mana_value(
.map(|obj| u32_to_i32_saturating(obj.effective_mana_value()))
.unwrap_or(0),
ObjectScope::EventSource => {
// CR 400.7 + CR 202.3: an event that pins its own subject incarnation
// answers for that incarnation directly. Reading live state (or the
// id-keyed LKI cache) would let a re-entered permanent at the same
// storage id supply the value instead — Narci draining for the NEW
// Saga's mana value after a blink. Checked first, so the id-based
// fallback below only runs for events with no pinned subject.
if let Some(mana_value) = event_source_mana_value_override(state) {
return mana_value;
}
let Some(object_id) =
object_id_for_scope(state, ObjectScope::EventSource, ctx, targets)
else {
Expand Down
12 changes: 7 additions & 5 deletions crates/engine/src/game/sba.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4347,15 +4347,17 @@ mod tests {
obj.card_types.core_types.push(CoreType::Enchantment);
obj.card_types.subtypes.push("Saga".to_string());
obj.entered_battlefield_turn = Some(state.turn_number);
// Add chapter triggers so final_chapter_number() works
// CR 714.2: add chapter triggers so final_chapter_number() works. The
// `saga_chapter` provenance is what marks these as chapter abilities —
// a bare lore threshold is not one (see `saga_chapter_numbers`).
for ch in 1..=final_chapter {
obj.trigger_definitions.push(
TriggerDefinition::new(TriggerMode::CounterAdded).counter_filter(
CounterTriggerFilter {
TriggerDefinition::new(TriggerMode::CounterAdded)
.counter_filter(CounterTriggerFilter {
counter_type: CounterType::Lore,
threshold: Some(ch),
},
),
})
.saga_chapter(ch),
);
}
id
Expand Down
Loading
Loading