diff --git a/client/src/adapter/types.ts b/client/src/adapter/types.ts index 2cfc7cd9f2..322ecc5ccd 100644 --- a/client/src/adapter/types.ts +++ b/client/src/adapter/types.ts @@ -2426,6 +2426,7 @@ export type GameEvent = | { type: "StackPushed"; data: { object_id: ObjectId } } | { type: "StackResolved"; data: { object_id: ObjectId } } | { type: "Discarded"; data: { player_id: PlayerId; object_id: ObjectId } } + | { type: "EnduringStoryGained"; data: { player_id: PlayerId } } | { type: "DamageCleared"; data: { object_id: ObjectId } } | { type: "GameOver"; data: { winner: PlayerId | null } } | { type: "DamageDealt"; data: { source_id: ObjectId; target: TargetRef; amount: number; is_combat: boolean; excess?: number } } @@ -3115,6 +3116,7 @@ export interface GameState { initiative?: PlayerId | null; monarch?: PlayerId | null; city_blessing?: PlayerId[]; + enduring_story?: PlayerId[]; ring_level?: Record; ring_bearer?: Record; commander_damage?: CommanderDamageEntry[]; diff --git a/client/src/components/board/OpponentSeatHeader.tsx b/client/src/components/board/OpponentSeatHeader.tsx index 4b9587b5ca..3b9c0b2260 100644 --- a/client/src/components/board/OpponentSeatHeader.tsx +++ b/client/src/components/board/OpponentSeatHeader.tsx @@ -14,6 +14,7 @@ import { ConditionBadge, CounterBadge, DungeonBadge, + EnduringStoryBadge, InitiativeBadge, MonarchBadge, PendingSpellBadge, @@ -188,6 +189,7 @@ export function OpponentSeatHeader({ playerId, compact = false, onKickPlayer }: {designations.isMonarch ? : null} {designations.hasInitiative ? : null} {designations.hasCityBlessing ? : null} + {designations.hasEnduringStory ? : null} {designations.activeDungeon ? ( + + 📖 + + + ); +} + interface DungeonBadgeProps { dungeonName: DungeonId; roomIndex: number; diff --git a/client/src/components/hud/OpponentHud.tsx b/client/src/components/hud/OpponentHud.tsx index 41a45c33ab..8b94024bf6 100644 --- a/client/src/components/hud/OpponentHud.tsx +++ b/client/src/components/hud/OpponentHud.tsx @@ -18,7 +18,7 @@ import { getOpponentIds, isOneOnOne, resolveFocusedOpponent } from "../../viewmo import { LifeTotal } from "../controls/LifeTotal.tsx"; import { ManaPoolSummary } from "./ManaPoolSummary.tsx"; import { ScoreBadge } from "../draft/ScoreBadge.tsx"; -import { CityBlessingBadge, CounterBadge, DungeonBadge, InitiativeBadge, MonarchBadge, StatusBadge, UnboundedBadge } from "./HudBadges.tsx"; +import { CityBlessingBadge, CounterBadge, DungeonBadge, EnduringStoryBadge, InitiativeBadge, MonarchBadge, StatusBadge, UnboundedBadge } from "./HudBadges.tsx"; import { AurasHoverPreview } from "./AurasHoverPreview.tsx"; import { AvatarHoverPreview } from "./AvatarHoverPreview.tsx"; import { BattlefieldPeekPopover } from "./BattlefieldPeekPopover.tsx"; @@ -305,6 +305,7 @@ export function OpponentHud({ {opponentDesignations.isMonarch ? : null} {opponentDesignations.hasInitiative ? : null} {opponentDesignations.hasCityBlessing ? : null} + {opponentDesignations.hasEnduringStory ? : null} {opponentDesignations.activeDungeon ? ( ) : null} @@ -761,6 +762,7 @@ function OpponentTab({ {designations.isMonarch ? : null} {designations.hasInitiative ? : null} {designations.hasCityBlessing ? : null} + {designations.hasEnduringStory ? : null} {designations.activeDungeon ? ( ) : null} diff --git a/client/src/components/hud/PlayerHud.tsx b/client/src/components/hud/PlayerHud.tsx index 01a6679ec3..b3771bced0 100644 --- a/client/src/components/hud/PlayerHud.tsx +++ b/client/src/components/hud/PlayerHud.tsx @@ -14,7 +14,7 @@ import { UndoButton } from "../board/UndoButton.tsx"; import { LifeTotal } from "../controls/LifeTotal.tsx"; import { ManaPoolSummary } from "./ManaPoolSummary.tsx"; import { PhaseIndicatorLeft, PhaseIndicatorRight } from "../controls/PhaseStopBar.tsx"; -import { CityBlessingBadge, ConditionBadge, CounterBadge, DungeonBadge, InitiativeBadge, MonarchBadge, PendingSpellBadge, RingBenefitsBadge, StatusBadge, UnboundedBadge } from "./HudBadges.tsx"; +import { CityBlessingBadge, ConditionBadge, CounterBadge, DungeonBadge, EnduringStoryBadge, InitiativeBadge, MonarchBadge, PendingSpellBadge, RingBenefitsBadge, StatusBadge, UnboundedBadge } from "./HudBadges.tsx"; import { EnchantmentsBadge } from "./EnchantmentsBadge.tsx"; import { HudPlate } from "./HudPlate.tsx"; import { NextUpBadge } from "./NextUpBadge.tsx"; @@ -106,6 +106,7 @@ export function PlayerHud() { {designations.isMonarch ? : null} {designations.hasInitiative ? : null} {designations.hasCityBlessing ? : null} + {designations.hasEnduringStory ? : null} {designations.activeDungeon ? ( ) : null} diff --git a/client/src/components/hud/__tests__/OpponentHud.designations.test.tsx b/client/src/components/hud/__tests__/OpponentHud.designations.test.tsx index 11547fee29..4d10a843bb 100644 --- a/client/src/components/hud/__tests__/OpponentHud.designations.test.tsx +++ b/client/src/components/hud/__tests__/OpponentHud.designations.test.tsx @@ -112,6 +112,14 @@ describe("OpponentHud designations (single-opponent path)", () => { expect(screen.getByLabelText("City's Blessing")).toBeInTheDocument(); }); + it("renders the enduring story badge for the opponent", () => { + act(() => { + useGameStore.setState({ gameState: createTwoPlayerState({ enduring_story: [1] }) }); + }); + render(); + expect(screen.getByLabelText("Enduring Story")).toBeInTheDocument(); + }); + it("renders the ring counter at the opponent's level", () => { act(() => { useGameStore.setState({ gameState: createTwoPlayerState({ ring_level: { "1": 4 } }) }); diff --git a/client/src/components/hud/__tests__/PlayerHud.designations.test.tsx b/client/src/components/hud/__tests__/PlayerHud.designations.test.tsx index a5cfbef229..06f631fad8 100644 --- a/client/src/components/hud/__tests__/PlayerHud.designations.test.tsx +++ b/client/src/components/hud/__tests__/PlayerHud.designations.test.tsx @@ -87,6 +87,16 @@ describe("PlayerHud designations", () => { }); }); + describe("Enduring Story", () => { + it("renders only for the designated local player", () => { + act(() => { + useGameStore.setState({ gameState: buildGameState({ enduring_story: [0] }) }); + }); + render(); + expect(screen.getByLabelText("Enduring Story")).toBeInTheDocument(); + }); + }); + describe("Ring level", () => { it("renders the ring counter at level 3 for the local player", () => { act(() => { diff --git a/client/src/hooks/usePlayerDesignations.ts b/client/src/hooks/usePlayerDesignations.ts index 5af7fa344d..cba7bfca17 100644 --- a/client/src/hooks/usePlayerDesignations.ts +++ b/client/src/hooks/usePlayerDesignations.ts @@ -16,6 +16,7 @@ export interface PlayerDesignations { isMonarch: boolean; hasInitiative: boolean; hasCityBlessing: boolean; + hasEnduringStory: boolean; ringLevel: number; ringBearerId: ObjectId | null; ringBearerName: string | null; @@ -61,6 +62,7 @@ const EMPTY: PlayerDesignations = { isMonarch: false, hasInitiative: false, hasCityBlessing: false, + hasEnduringStory: false, ringLevel: 0, ringBearerId: null, ringBearerName: null, @@ -97,6 +99,7 @@ export function usePlayerDesignations(playerId: PlayerId): PlayerDesignations { const isMonarch = gs.monarch != null && gs.monarch === playerId; const hasInitiative = gs.initiative != null && gs.initiative === playerId; const hasCityBlessing = gs.city_blessing?.includes(playerId) ?? false; + const hasEnduringStory = gs.enduring_story?.includes(playerId) ?? false; const ringLevel = gs.ring_level?.[playerKey(playerId)] ?? 0; const ringBearerId = gs.ring_bearer?.[playerKey(playerId)] ?? null; const ringBearerName = ringBearerId != null ? (gs.objects[String(ringBearerId)]?.name ?? null) : null; @@ -124,6 +127,7 @@ export function usePlayerDesignations(playerId: PlayerId): PlayerDesignations { isMonarch || hasInitiative || hasCityBlessing + || hasEnduringStory || activeDungeon != null || ringLevel > 0 || energy > 0 @@ -136,6 +140,7 @@ export function usePlayerDesignations(playerId: PlayerId): PlayerDesignations { isMonarch, hasInitiative, hasCityBlessing, + hasEnduringStory, ringLevel, ringBearerId, ringBearerName, @@ -151,3 +156,8 @@ export function usePlayerDesignations(playerId: PlayerId): PlayerDesignations { }; }, [gameState, playerId]); } + +/** Engine-projected player designation; this hook deliberately performs only membership lookup. */ +export function useHasEnduringStory(playerId: PlayerId): boolean { + return useGameStore((state) => state.gameState?.enduring_story?.includes(playerId) ?? false); +} diff --git a/client/src/i18n/locales/de/game.json b/client/src/i18n/locales/de/game.json index 6779703dce..3dd278b05b 100644 --- a/client/src/i18n/locales/de/game.json +++ b/client/src/i18n/locales/de/game.json @@ -441,6 +441,8 @@ "initiativeTooltip": "Hat die Initiative — wagt sich zu Beginn der Versorgung in die Unterstadt vor", "cityBlessing": "Segen der Stadt", "cityBlessingTooltip": "Segen der Stadt — kontrolliert zehn oder mehr bleibende Karten (Aufstieg)", + "enduringStory": "Bleibende Geschichte", + "enduringStoryTooltip": "Bleibende Geschichte — erhalten, wenn du eine Storied bleibende Karte und drei oder mehr Artefakte, legendäre bleibende Karten und/oder Sagen kontrollierst; bleibt für den Rest der Partie bestehen", "dungeonAriaLabel": "Wagt sich in {{name}} vor, Raum {{room}}", "dungeonTooltip": "Wagt sich in {{name}} vor — Raum {{room}}", "poisonAriaLabel_one": "{{count}} Giftmarke", diff --git a/client/src/i18n/locales/en/game.json b/client/src/i18n/locales/en/game.json index adb97e40ae..aadcdfd0a7 100644 --- a/client/src/i18n/locales/en/game.json +++ b/client/src/i18n/locales/en/game.json @@ -480,6 +480,8 @@ "initiativeTooltip": "Has the Initiative — ventures into Undercity at start of upkeep", "cityBlessing": "City's Blessing", "cityBlessingTooltip": "City's Blessing — controls ten or more permanents (Ascend)", + "enduringStory": "Enduring Story", + "enduringStoryTooltip": "Enduring Story — gained by controlling a Storied permanent and three or more artifacts, legendary permanents, and/or Sagas; lasts for the rest of the game", "dungeonAriaLabel": "Venturing in {{name}}, room {{room}}", "dungeonTooltip": "Venturing in {{name}} — room {{room}}", "poisonAriaLabel_one": "{{count}} poison counter", diff --git a/client/src/i18n/locales/es/game.json b/client/src/i18n/locales/es/game.json index dcd0238323..88231b9f58 100644 --- a/client/src/i18n/locales/es/game.json +++ b/client/src/i18n/locales/es/game.json @@ -441,6 +441,8 @@ "initiativeTooltip": "Tiene la Iniciativa — se aventura en la Ciudad Baja al inicio del mantenimiento", "cityBlessing": "Bendición de la ciudad", "cityBlessingTooltip": "Bendición de la ciudad — controla diez o más permanentes (Ascender)", + "enduringStory": "Historia perdurable", + "enduringStoryTooltip": "Historia perdurable — se obtiene al controlar un permanente con Storied y tres o más artefactos, permanentes legendarios y/o sagas; dura el resto del juego", "dungeonAriaLabel": "Aventurándose en {{name}}, sala {{room}}", "dungeonTooltip": "Aventurándose en {{name}} — sala {{room}}", "poisonAriaLabel_one": "{{count}} contador de veneno", diff --git a/client/src/i18n/locales/fr/game.json b/client/src/i18n/locales/fr/game.json index bfedbbd0b2..5b7f4d2a65 100644 --- a/client/src/i18n/locales/fr/game.json +++ b/client/src/i18n/locales/fr/game.json @@ -441,6 +441,8 @@ "initiativeTooltip": "Détient l'Initiative — s'aventure dans la Ville souterraine au début de l'entretien", "cityBlessing": "Bénédiction de la cité", "cityBlessingTooltip": "Bénédiction de la cité — contrôle dix permanents ou plus (Ascension)", + "enduringStory": "Histoire durable", + "enduringStoryTooltip": "Histoire durable — obtenue en contrôlant un permanent avec Storied et au moins trois artefacts, permanents légendaires et/ou sagas ; dure pour le reste de la partie", "dungeonAriaLabel": "Aventure dans {{name}}, salle {{room}}", "dungeonTooltip": "Aventure dans {{name}} — salle {{room}}", "poisonAriaLabel_one": "{{count}} marqueur poison", diff --git a/client/src/i18n/locales/it/game.json b/client/src/i18n/locales/it/game.json index 3f4a0e9df5..af26775051 100644 --- a/client/src/i18n/locales/it/game.json +++ b/client/src/i18n/locales/it/game.json @@ -441,6 +441,8 @@ "initiativeTooltip": "Ha l'Iniziativa — si avventura nella Città Bassa all'inizio del mantenimento", "cityBlessing": "Benedizione della Città", "cityBlessingTooltip": "Benedizione della Città — controlla dieci o più permanenti (Ascendere)", + "enduringStory": "Storia duratura", + "enduringStoryTooltip": "Storia duratura — ottenuta controllando un permanente con Storied e tre o più artefatti, permanenti leggendari e/o saghe; dura per il resto della partita", "dungeonAriaLabel": "Avventura in {{name}}, stanza {{room}}", "dungeonTooltip": "Avventura in {{name}} — stanza {{room}}", "poisonAriaLabel_one": "{{count}} segnalino veleno", diff --git a/client/src/i18n/locales/pl/game.json b/client/src/i18n/locales/pl/game.json index c6ea85123b..6b5110009c 100644 --- a/client/src/i18n/locales/pl/game.json +++ b/client/src/i18n/locales/pl/game.json @@ -441,6 +441,8 @@ "initiativeTooltip": "Ma InicjatywÄ™ — wyprawia siÄ™ do Podmiasta na poczÄ…tku utrzymania", "cityBlessing": "BÅ‚ogosÅ‚awieÅ„stwo miasta", "cityBlessingTooltip": "BÅ‚ogosÅ‚awieÅ„stwo miasta — kontroluje dziesięć lub wiÄ™cej trwaÅ‚ych (Ascend)", + "enduringStory": "TrwaÅ‚a historia", + "enduringStoryTooltip": "TrwaÅ‚a historia — zdobyta za kontrolowanie permanenta ze Storied i co najmniej trzech artefaktów, legendarnych permanentów i/lub Sag; trwa do koÅ„ca gry", "dungeonAriaLabel": "Wyprawa w {{name}}, pokój {{room}}", "dungeonTooltip": "Wyprawa w {{name}} — pokój {{room}}", "poisonAriaLabel_one": "{{count}} znacznik trucizny", diff --git a/client/src/i18n/locales/pt/game.json b/client/src/i18n/locales/pt/game.json index 7474e075bd..03fed33719 100644 --- a/client/src/i18n/locales/pt/game.json +++ b/client/src/i18n/locales/pt/game.json @@ -441,6 +441,8 @@ "initiativeTooltip": "Tem a Iniciativa — aventura-se na Subcidade no início da manutenção", "cityBlessing": "Bênção da Cidade", "cityBlessingTooltip": "Bênção da Cidade — controla dez ou mais permanentes (Ascender)", + "enduringStory": "História duradoura", + "enduringStoryTooltip": "História duradoura — obtida ao controlar um permanente com Storied e três ou mais artefatos, permanentes lendários e/ou sagas; dura pelo resto do jogo", "dungeonAriaLabel": "Aventurando-se em {{name}}, sala {{room}}", "dungeonTooltip": "Aventurando-se em {{name}} — sala {{room}}", "poisonAriaLabel_one": "{{count}} marcador de veneno", diff --git a/crates/engine/src/ai_support/filter.rs b/crates/engine/src/ai_support/filter.rs index b06267ceab..6c51fa4d53 100644 --- a/crates/engine/src/ai_support/filter.rs +++ b/crates/engine/src/ai_support/filter.rs @@ -996,6 +996,7 @@ fn condition_reads_only_memo_safe_state(c: &ParsedCondition) -> bool { | ParsedCondition::CardsLeftYourGraveyardThisTurnAtLeast { .. } | ParsedCondition::PlayerCountAtLeast { .. } | ParsedCondition::HasCityBlessing + | ParsedCondition::HasEnduringStory // CR 903.3 / CR 903.3d: a controller-scoped battlefield scan for a commander // (via `game::commander`), like the other `YouControl*` predicates — reads no // combat/damage/pending-cast history, so it is memo-safe. diff --git a/crates/engine/src/game/ability_rw.rs b/crates/engine/src/game/ability_rw.rs index 08f79eeb71..9c4c0af93e 100644 --- a/crates/engine/src/game/ability_rw.rs +++ b/crates/engine/src/game/ability_rw.rs @@ -1861,6 +1861,7 @@ fn legacy_trigger_condition(x: &TriggerCondition) -> bool { | TriggerCondition::IsInitiative | TriggerCondition::NoMonarch | TriggerCondition::HasCityBlessing + | TriggerCondition::HasEnduringStory | TriggerCondition::CompletedDungeon { .. } | TriggerCondition::TributeNotPaid | TriggerCondition::CastDuringPhase { .. } @@ -1879,6 +1880,7 @@ fn legacy_ability_condition(x: &AbilityCondition) -> bool { } AbilityCondition::PreviousEffectAmount { rhs, .. } => legacy_quantity_expr(rhs), AbilityCondition::ScopedPlayerMatches { filter } => legacy_player_filter(filter), + AbilityCondition::DiscardedCardMatchesFilter { filter } => legacy_target_filter(filter), AbilityCondition::ConditionInstead { inner } | AbilityCondition::Not { condition: inner } => legacy_ability_condition(inner), AbilityCondition::And { conditions } | AbilityCondition::Or { conditions } => { @@ -1923,6 +1925,7 @@ fn legacy_ability_condition(x: &AbilityCondition) -> bool { | AbilityCondition::CompletedDungeon { .. } | AbilityCondition::IsInitiative | AbilityCondition::HasCityBlessing + | AbilityCondition::HasEnduringStory | AbilityCondition::IsRingBearer | AbilityCondition::TargetHasKeywordInstead { .. } | AbilityCondition::HasObjectTarget @@ -1984,6 +1987,7 @@ fn legacy_static_condition(x: &StaticCondition) -> bool { | StaticCondition::IsInitiative | StaticCondition::NoMonarch | StaticCondition::HasCityBlessing + | StaticCondition::HasEnduringStory | StaticCondition::CompletedADungeon | StaticCondition::Unrecognized { .. } | StaticCondition::DuringYourTurn @@ -6093,6 +6097,7 @@ fn rw_ability_condition(x: &AbilityCondition) -> RwProfile { // feed — the read is the write's own reveal output). So `conservative()` // (which the coarse fallback assigned) falsely conflicts. AbilityCondition::RevealedHasCardType { .. } => RwProfile::empty(), + AbilityCondition::DiscardedCardMatchesFilter { .. } => RwProfile::empty(), AbilityCondition::SourceEnteredThisTurn | AbilityCondition::AdditionalCostPaid { .. } | AbilityCondition::CastVariantPaid { .. } @@ -6126,6 +6131,7 @@ fn rw_ability_condition(x: &AbilityCondition) -> RwProfile { | AbilityCondition::CompletedDungeon { .. } | AbilityCondition::IsInitiative | AbilityCondition::HasCityBlessing + | AbilityCondition::HasEnduringStory | AbilityCondition::IsRingBearer | AbilityCondition::TargetHasKeywordInstead { .. } | AbilityCondition::HasObjectTarget @@ -6243,6 +6249,7 @@ fn rw_trigger_condition(x: &TriggerCondition) -> RwProfile { | TriggerCondition::IsInitiative | TriggerCondition::NoMonarch | TriggerCondition::HasCityBlessing + | TriggerCondition::HasEnduringStory | TriggerCondition::CompletedDungeon { .. } | TriggerCondition::TributeNotPaid | TriggerCondition::CastDuringPhase { .. } @@ -6331,6 +6338,7 @@ fn rw_static_condition(x: &StaticCondition) -> RwProfile { | StaticCondition::IsInitiative | StaticCondition::NoMonarch | StaticCondition::HasCityBlessing + | StaticCondition::HasEnduringStory | StaticCondition::CompletedADungeon | StaticCondition::Unrecognized { .. } | StaticCondition::DuringYourTurn diff --git a/crates/engine/src/game/ability_scan.rs b/crates/engine/src/game/ability_scan.rs index 1e2bde8df2..729ca1c885 100644 --- a/crates/engine/src/game/ability_scan.rs +++ b/crates/engine/src/game/ability_scan.rs @@ -2645,6 +2645,10 @@ fn scan_ability_condition(x: &AbilityCondition, mode: ScanMode) -> Axes { AbilityCondition::CompletedDungeon { .. } => Axes::NONE, AbilityCondition::IsInitiative => Axes::NONE, AbilityCondition::HasCityBlessing => Axes::NONE, + AbilityCondition::HasEnduringStory => Axes::NONE, + AbilityCondition::DiscardedCardMatchesFilter { filter } => { + scan_target_filter(filter, FilterReadContext::SnapshotOrEvent, mode) + } AbilityCondition::IsRingBearer => Axes::NONE, AbilityCondition::TargetHasKeywordInstead { keyword: _ } => Axes::NONE, // `subject_slot: _` is a target-slot INDEX selector (CR 608.2c): `Some(n)` @@ -3265,6 +3269,7 @@ fn scan_trigger_condition(x: &TriggerCondition, mode: ScanMode) -> Axes { projected: true, }, TriggerCondition::HasCityBlessing => Axes::NONE, + TriggerCondition::HasEnduringStory => Axes::NONE, TriggerCondition::CompletedDungeon { specific: _ } => Axes::NONE, TriggerCondition::SourceIsTapped => Axes::NONE, TriggerCondition::SourceIsTransformed => Axes::NONE, @@ -3552,6 +3557,7 @@ fn scan_static_condition(x: &StaticCondition, mode: ScanMode) -> Axes { StaticCondition::IsInitiative => Axes::NONE, StaticCondition::NoMonarch => Axes::NONE, StaticCondition::HasCityBlessing => Axes::NONE, + StaticCondition::HasEnduringStory => Axes::NONE, StaticCondition::CompletedADungeon => Axes::NONE, StaticCondition::WasStartingPlayer { controller, .. } => { let mut acc = Axes::NONE; @@ -4564,6 +4570,7 @@ pub(crate) fn keyword_cost_reads_growing_class(kw: &Keyword) -> bool { | Keyword::Exploit | Keyword::Explore | Keyword::Ascend + | Keyword::Storied | Keyword::StartYourEngines | Keyword::Dredge(_) | Keyword::Modular(_) @@ -4829,6 +4836,7 @@ fn scan_keyword(kw: &Keyword, mode: ScanMode) -> Axes { | Keyword::Exploit | Keyword::Explore | Keyword::Ascend + | Keyword::Storied | Keyword::StartYourEngines | Keyword::Dredge(_) | Keyword::Modular(_) diff --git a/crates/engine/src/game/conditions.rs b/crates/engine/src/game/conditions.rs index f2001df805..8f15e8d2a1 100644 --- a/crates/engine/src/game/conditions.rs +++ b/crates/engine/src/game/conditions.rs @@ -176,6 +176,11 @@ pub(crate) fn eval_has_city_blessing(state: &GameState, controller: PlayerId) -> state.city_blessing.contains(&controller) } +/// CR 702.195b: True when the given player has the enduring story designation. +pub(crate) fn eval_has_enduring_story(state: &GameState, controller: PlayerId) -> bool { + state.enduring_story.contains(&controller) +} + /// CR 400.7: True when the source permanent entered the battlefield this turn. pub(crate) fn eval_source_entered_this_turn(state: &GameState, source_id: ObjectId) -> bool { state diff --git a/crates/engine/src/game/costs.rs b/crates/engine/src/game/costs.rs index 48dfdb0649..71b12cae69 100644 --- a/crates/engine/src/game/costs.rs +++ b/crates/engine/src/game/costs.rs @@ -1094,6 +1094,7 @@ fn pay_ability_cost_inner( effect_kind: EffectKind::PayCost, up_to: false, unless_filter: None, + discard_frame: None, }; } } diff --git a/crates/engine/src/game/coverage.rs b/crates/engine/src/game/coverage.rs index 454dc6368e..9336379b5d 100644 --- a/crates/engine/src/game/coverage.rs +++ b/crates/engine/src/game/coverage.rs @@ -3924,6 +3924,10 @@ fn fmt_ability_condition(cond: &AbilityCondition) -> String { }, AbilityCondition::IsInitiative => "has the initiative".into(), AbilityCondition::HasCityBlessing => "has the city's blessing".into(), + AbilityCondition::HasEnduringStory => "has an enduring story".into(), + AbilityCondition::DiscardedCardMatchesFilter { filter } => { + format!("discarded card matches {}", fmt_target(filter)) + } AbilityCondition::IsRingBearer => "is the ring-bearer".into(), AbilityCondition::TargetHasKeywordInstead { keyword } => { format!("target has {} (instead)", keyword_label(keyword)) @@ -4070,6 +4074,7 @@ fn fmt_trigger_condition(cond: &crate::types::ability::TriggerCondition) -> Stri "a spell was cast with this variant this turn".into() } TC::HasCityBlessing => "has the city's blessing".into(), + TC::HasEnduringStory => "has an enduring story".into(), TC::CompletedDungeon { .. } => "completed a dungeon".into(), TC::SourceIsTapped => "source is tapped".into(), TC::SourceIsTransformed => "source is transformed".into(), @@ -4226,6 +4231,7 @@ fn fmt_static_condition(cond: &StaticCondition) -> String { SC::IsInitiative => "has the initiative".into(), SC::NoMonarch => "no monarch".into(), SC::HasCityBlessing => "has the city's blessing".into(), + SC::HasEnduringStory => "has an enduring story".into(), SC::CompletedADungeon => "completed a dungeon".into(), SC::WasStartingPlayer { .. } => "was the starting player".into(), SC::SpellCastWithVariantThisTurn { .. } => { @@ -7425,6 +7431,10 @@ fn condition_feature(cond: &AbilityCondition) -> (&'static str, FeatureSupport) AbilityCondition::CompletedDungeon { .. } => ("CompletedDungeon", Handled), AbilityCondition::IsInitiative => ("IsInitiative", Handled), AbilityCondition::HasCityBlessing => ("HasCityBlessing", Handled), + AbilityCondition::HasEnduringStory => ("HasEnduringStory", Handled), + AbilityCondition::DiscardedCardMatchesFilter { .. } => { + ("DiscardedCardMatchesFilter", Handled) + } AbilityCondition::IsRingBearer => ("IsRingBearer", Handled), AbilityCondition::TargetHasKeywordInstead { .. } => ("TargetHasKeywordInstead", Handled), // CR 608.2c: active-player check; handled by `evaluate_condition` (effects/mod.rs). @@ -7828,6 +7838,7 @@ fn static_condition_feature(cond: &StaticCondition) -> (&'static str, FeatureSup StaticCondition::IsInitiative => ("IsInitiative", Handled), StaticCondition::NoMonarch => ("NoMonarch", Handled), StaticCondition::HasCityBlessing => ("HasCityBlessing", Handled), + StaticCondition::HasEnduringStory => ("HasEnduringStory", Handled), StaticCondition::CompletedADungeon => ("CompletedADungeon", Unhandled), // CR 103.1: bridges to Ability/Trigger `WasStartingPlayer`, both runtime-handled. StaticCondition::WasStartingPlayer { .. } => ("WasStartingPlayer", Handled), diff --git a/crates/engine/src/game/effects/discard.rs b/crates/engine/src/game/effects/discard.rs index 5335390454..4a29e0393f 100644 --- a/crates/engine/src/game/effects/discard.rs +++ b/crates/engine/src/game/effects/discard.rs @@ -25,6 +25,16 @@ pub(crate) enum DiscardOutcome { NeedsReplacementChoice(PlayerId), } +/// Retires a Recruit-owned discard frame after its discard event was prevented. +fn retire_discard_frame( + state: &mut GameState, + frame_id: crate::types::identifiers::DiscardFrameId, +) { + if let Ok(Some(frame)) = state.resolution_stack.take_active_discard() { + debug_assert_eq!(frame.id, frame_id); + } +} + /// CR 701.9a: To discard a card, move it from its owner's hand to their graveyard. /// CR 614.6: A `Moved` replacement ("if a card would be put into a graveyard, /// exile it instead" — Rest in Peace / Leyline of the Void) watches the @@ -42,6 +52,7 @@ pub(crate) fn complete_discard_to_graveyard( object_id: ObjectId, player_id: PlayerId, source_id: Option, + discard_frame: Option, applied: HashSet, events: &mut Vec, ) -> DiscardOutcome { @@ -61,6 +72,7 @@ pub(crate) fn complete_discard_to_graveyard( enter_transformed: false, face_down_profile: None, enter_as_copy: None, + discard_frame, applied, }; match replacement::replace_event(state, proposed, events) { @@ -84,28 +96,23 @@ pub(crate) fn complete_discard_to_graveyard( // `Discarded` event. This is distinct from a REDIRECTED discard // (CR 701.9c: a card put elsewhere instead is still discarded — // the Execute arm above and the madness path both record + emit). + if let Some(frame_id) = discard_frame { + retire_discard_frame(state, frame_id); + } return DiscardOutcome::Complete; } ReplacementResult::NeedsChoice(player) => { - // KNOWN GAP (documented, counter.rs resolve_all style): a CR 616.1 - // ordering choice on the inner ZoneChange (TWO materially-different - // Moved redirects simultaneously applicable to one discard — e.g. - // Rest in Peace + Wheel of Sun and Moon) parks here BEFORE the - // discard bookkeeping. The paused card delivers via the generic - // replacement-choice resume (`handle_replacement_choice`'s - // ZoneChange arm), which has no discard context — so for that card - // `record_discard` / the Mayhem stamp / the `Discarded` event - // ("whenever you discard" triggers, Megrim class) are skipped, and - // a multi-card discard's remaining cards are abandoned by the - // caller's early return (same systemic shape as the forced-discard - // EffectResolved limitation noted in `resolve`). A resume-side - // discard-bookkeeping continuation needs a new serialized state - // slot + resume wiring; not built for a two-material-redirect board - // no parsed deck assembles. Single-redirect boards (RIP alone) - // never prompt and are fully correct. + // CR 616.1: The event retains `discard_frame` on the paused + // ZoneChange. Generic replacement resume returns to terminal zone + // delivery, which appends the exact result and emits bookkeeping. return DiscardOutcome::NeedsReplacementChoice(player); } } + if discard_frame.is_some() { + // Provenance-backed delivery already emitted the single discard event + // and recorded the normal bookkeeping at its terminal point. + return DiscardOutcome::Complete; + } crate::game::restrictions::record_discard(state, player_id); // CR 701.9c + CR 702.187b: stamp the Mayhem discard marker only if the card // actually landed in the graveyard — a redirect (RIP → exile) leaves it @@ -121,6 +128,31 @@ pub(crate) fn complete_discard_to_graveyard( DiscardOutcome::Complete } +/// Hands the terminal result of a Recruit discard to its directly contingent +/// continuation. This is called only after terminal zone delivery: a +/// replacement choice may keep the operation parked until that point. +pub(crate) fn hand_off_recruit_discard_result( + state: &mut GameState, + frame_id: crate::types::identifiers::DiscardFrameId, +) -> bool { + let result = state + .resolution_stack + .active_ability_continuation_discard_parent_result(frame_id); + let Some(continuation) = state + .resolution_stack + .active_ability_continuation_with_discard_parent_mut(frame_id) + else { + return false; + }; + if let Some(result) = result { + continuation + .pending + .chain + .set_direct_discard_result_for_immediate_node(result); + } + true +} + /// CR 701.9a: To discard a card, move it from owner's hand to their graveyard. /// If targets specify specific cards, discard those; otherwise discard from end of hand. pub fn resolve( @@ -128,6 +160,19 @@ pub fn resolve( ability: &ResolvedAbility, events: &mut Vec, ) -> Result<(), EffectError> { + let discard_frame = ability + .sub_ability + .as_ref() + .and_then(|sub| match sub.condition.as_ref() { + Some(crate::types::ability::AbilityCondition::DiscardedCardMatchesFilter { + .. + }) => Some( + state + .resolution_stack + .begin_discard(Some(ability.source_id)), + ), + _ => None, + }); // CR 701.9b + CR 608.2d: Peel `UpTo` from the count expression to derive // the upper-bound expression and the may-pick-fewer flag. Plain // `QuantityExpr` means a mandatory count; wrapped in `UpTo` means the @@ -260,6 +305,7 @@ pub fn resolve( object_id: obj_id, source_id: Some(ability.source_id), caused_by_effect: true, + discard_frame, applied: HashSet::new(), }; @@ -269,6 +315,7 @@ pub fn resolve( ProposedEvent::Discard { player_id: pid, object_id: oid, + discard_frame, applied, .. } => { @@ -278,6 +325,7 @@ pub fn resolve( oid, pid, Some(ability.source_id), + discard_frame, applied, events, ) @@ -289,7 +337,11 @@ pub fn resolve( return Ok(()); } } - zone_event @ ProposedEvent::ZoneChange { object_id: oid, .. } => { + zone_event @ ProposedEvent::ZoneChange { + object_id: oid, + discard_frame, + .. + } => { // Replacement redirected (e.g., Madness → exile instead of graveyard). // The lowered ZoneChange already re-looped through the // pipeline (CR 616.1f), so `Moved` redirects were consulted. @@ -303,19 +355,23 @@ pub fn resolve( None, events, ); - // CR 702.35: The card was still discarded — record and emit event - // so "whenever you discard" triggers fire. - crate::game::restrictions::record_discard(state, player_id); - events.push(GameEvent::Discarded { - player_id, - object_id: oid, - source_id: Some(ability.source_id), - }); + if discard_frame.is_none() { + crate::game::restrictions::record_discard(state, player_id); + events.push(GameEvent::Discarded { + player_id, + object_id: oid, + source_id: Some(ability.source_id), + }); + } } _ => {} } } - ReplacementResult::Prevented => {} + ReplacementResult::Prevented => { + if let Some(frame_id) = discard_frame { + retire_discard_frame(state, frame_id); + } + } ReplacementResult::NeedsChoice(player) => { state.waiting_for = crate::game::replacement::replacement_choice_waiting_for(player, state); @@ -363,11 +419,12 @@ pub fn resolve( let index = state.rng.random_range(0..remaining.len()); let obj_id = remaining.swap_remove(index); if let DiscardOutcome::NeedsReplacementChoice(player) = - discard_caused_by_effect_with_source( + discard_caused_by_effect_with_source_and_frame( state, obj_id, discard_player, Some(ability.source_id), + discard_frame, events, ) { @@ -383,11 +440,12 @@ pub fn resolve( // When up_to=true, always present the choice (player may discard fewer). for obj_id in &hand_cards { if let DiscardOutcome::NeedsReplacementChoice(player) = - discard_caused_by_effect_with_source( + discard_caused_by_effect_with_source_and_frame( state, *obj_id, discard_player, Some(ability.source_id), + discard_frame, events, ) { @@ -408,6 +466,7 @@ pub fn resolve( effect_kind: EffectKind::from(&ability.effect), up_to, unless_filter, + discard_frame, }; // EffectResolved is emitted by the engine handler after the player chooses. return Ok(()); @@ -441,7 +500,7 @@ pub(crate) fn discard_as_cost_with_source( source_id: Option, events: &mut Vec, ) -> DiscardOutcome { - route_discard(state, object_id, player, source_id, false, events) + route_discard(state, object_id, player, source_id, false, None, events) } /// CR 701.9a + CR 614.1a: Discard caused by resolving a spell or ability effect @@ -454,7 +513,29 @@ pub(crate) fn discard_caused_by_effect_with_source( source_id: Option, events: &mut Vec, ) -> DiscardOutcome { - route_discard(state, object_id, player, source_id, true, events) + discard_caused_by_effect_with_source_and_frame( + state, object_id, player, source_id, None, events, + ) +} + +/// Resolving-effect discard with optional operation-owned provenance. +pub(crate) fn discard_caused_by_effect_with_source_and_frame( + state: &mut GameState, + object_id: ObjectId, + player: PlayerId, + source_id: Option, + discard_frame: Option, + events: &mut Vec, +) -> DiscardOutcome { + route_discard( + state, + object_id, + player, + source_id, + true, + discard_frame, + events, + ) } fn route_discard( @@ -463,6 +544,7 @@ fn route_discard( player: PlayerId, source_id: Option, caused_by_effect: bool, + discard_frame: Option, events: &mut Vec, ) -> DiscardOutcome { let proposed = ProposedEvent::Discard { @@ -470,6 +552,7 @@ fn route_discard( object_id, source_id, caused_by_effect, + discard_frame, applied: HashSet::new(), }; match replacement::replace_event(state, proposed, events) { @@ -477,16 +560,29 @@ fn route_discard( ProposedEvent::Discard { player_id: pid, object_id: oid, + discard_frame, applied, .. } => { if let DiscardOutcome::NeedsReplacementChoice(choice_player) = - complete_discard_to_graveyard(state, oid, pid, source_id, applied, events) + complete_discard_to_graveyard( + state, + oid, + pid, + source_id, + discard_frame, + applied, + events, + ) { return DiscardOutcome::NeedsReplacementChoice(choice_player); } } - zone_event @ ProposedEvent::ZoneChange { object_id: oid, .. } => { + zone_event @ ProposedEvent::ZoneChange { + object_id: oid, + discard_frame, + .. + } => { // CR 614.1c: Replacement redirected destination (e.g., Madness → exile). // The lowered ZoneChange already re-looped through the pipeline // (CR 616.1f), so `Moved` redirects were consulted. @@ -502,12 +598,14 @@ fn route_discard( None, events, ); - crate::game::restrictions::record_discard(state, player); - events.push(GameEvent::Discarded { - player_id: player, - object_id: oid, - source_id, - }); + if discard_frame.is_none() { + crate::game::restrictions::record_discard(state, player); + events.push(GameEvent::Discarded { + player_id: player, + object_id: oid, + source_id, + }); + } } _ => {} }, @@ -528,16 +626,109 @@ mod tests { use crate::game::engine::apply_as_current; use crate::game::zones::create_object; use crate::types::ability::{ - AbilityCondition, AbilityDefinition, AbilityKind, ControllerRef, EffectOutcomeSignal, - LibraryPosition, QuantityExpr, ReplacementCondition, ReplacementDefinition, - ReplacementMode, SubAbilityLink, TargetFilter, TypedFilter, + AbilityCondition, AbilityDefinition, AbilityKind, ControllerRef, DiscardedCardResult, + EffectOutcomeSignal, LibraryPosition, QuantityExpr, ReplacementCondition, + ReplacementDefinition, ReplacementMode, ResolvedAbility, SubAbilityLink, TargetFilter, + TypedFilter, }; use crate::types::actions::GameAction; use crate::types::counter::CounterType; - use crate::types::game_state::WaitingFor; + use crate::types::game_state::{PendingContinuation, WaitingFor}; use crate::types::identifiers::{CardId, ObjectId}; use crate::types::player::PlayerId; use crate::types::replacements::ReplacementEvent; + use crate::types::resolution::ChangeZoneFrame; + + #[test] + fn recruit_handoff_waits_for_direct_continuation_and_clears_descendants() { + let mut state = GameState::new_two_player(91); + let card = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Discarded nonland".to_string(), + Zone::Hand, + ); + let result = DiscardedCardResult { + object_id: card, + lki: state.objects[&card].snapshot_for_mana_spent(), + final_zone: Zone::Graveyard, + }; + let discard_frame = state.resolution_stack.begin_discard(Some(ObjectId(99))); + state + .resolution_stack + .active_discard_mut() + .expect("new Recruit frame exists") + .results + .push(result.clone()); + + let grandchild = ResolvedAbility::new( + Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + }, + vec![], + ObjectId(99), + PlayerId(0), + ); + let else_branch = grandchild.clone(); + let mut direct_child = grandchild.clone(); + direct_child.sub_ability = Some(Box::new(grandchild)); + direct_child.else_ability = Some(Box::new(else_branch)); + direct_child + .sub_ability + .as_mut() + .expect("test grandchild exists") + .context + .direct_discard_result = Some(result.clone()); + direct_child + .else_ability + .as_mut() + .expect("test else branch exists") + .context + .direct_discard_result = Some(result.clone()); + let continuation = PendingContinuation::new(Box::new(direct_child), &state); + state.park_ability_continuation(continuation); + + // Model the nested ZoneChange child owned by a replacement resume: + // the continuation is deliberately buried until that child settles. + state.push_change_zone_frame(ChangeZoneFrame { + pending: None, + devour_eligible_snapshot: None, + }); + assert!( + !hand_off_recruit_discard_result(&mut state, discard_frame), + "a buried continuation must not consume the operation-owned result" + ); + state + .take_active_change_zone_frame() + .expect("nested child is active") + .expect("nested child exists"); + assert!( + hand_off_recruit_discard_result(&mut state, discard_frame), + "the direct continuation receives the result once it becomes active" + ); + let direct = state + .active_ability_continuation() + .expect("direct continuation remains active after hand-off"); + assert!(direct.chain.context.direct_discard_result.is_some()); + assert!( + direct + .chain + .sub_ability + .as_ref() + .is_some_and(|sub| sub.context.direct_discard_result.is_none()), + "a Recruit result must not leak into a grandchild" + ); + assert!( + direct + .chain + .else_ability + .as_ref() + .is_some_and(|branch| branch.context.direct_discard_result.is_none()), + "a Recruit result must not leak into an alternate branch" + ); + } fn library_of_leng_discard_replacement() -> ReplacementDefinition { ReplacementDefinition::new(ReplacementEvent::Discard) @@ -1545,6 +1736,7 @@ mod tests { effect_kind: crate::types::ability::EffectKind::Discard, up_to: true, unless_filter: None, + discard_frame: None, }; // Select zero cards — should succeed with up_to=true diff --git a/crates/engine/src/game/effects/mod.rs b/crates/engine/src/game/effects/mod.rs index dfe812649d..e1df72586b 100644 --- a/crates/engine/src/game/effects/mod.rs +++ b/crates/engine/src/game/effects/mod.rs @@ -2,7 +2,7 @@ use std::borrow::Cow; use std::collections::{HashMap, HashSet, VecDeque}; use crate::game::conditions::{ - eval_has_city_blessing, eval_is_initiative, eval_is_monarch, + eval_has_city_blessing, eval_has_enduring_story, eval_is_initiative, eval_is_monarch, eval_source_attached_to_controlled_creature, eval_source_entered_this_turn, eval_source_is_tapped, }; @@ -772,10 +772,30 @@ pub(crate) fn drain_pending_continuation(state: &mut GameState, events: &mut Vec if !waits_for_resolution_choice(&state.waiting_for) && state.active_ability_continuation().is_some() { - let frame = state + let discard_frame = state + .resolution_stack + .active_ability_continuation_discard_parent_id(); + let mut frame = state .take_active_ability_continuation() .expect("checked active continuation must be consumable") .expect("checked active continuation must exist"); + if let Some(discard_id) = discard_frame { + let discard = state + .resolution_stack + .take_active_discard() + .expect("direct Recruit parent must be active after its child is popped") + .expect("direct Recruit parent must exist"); + assert_eq!( + discard.id, discard_id, + "direct Recruit parent id must remain adjacent" + ); + if let Some(result) = discard.results.into_iter().next() { + frame + .pending + .chain + .set_direct_discard_result_for_immediate_node(result); + } + } let cont = frame.pending; let PendingContinuation { chain, @@ -878,6 +898,7 @@ pub(crate) fn resume_resolution_frames(state: &mut GameState, events: &mut Vec { drain_pending_continuation(state, events) } + ResolutionFrame::Discard(_) => {} ResolutionFrame::RepeatFor(_) => drain_active_repeat_for(state, events), ResolutionFrame::RepeatUntil(_) => drain_active_repeat_until(state), ResolutionFrame::RepeatedOptionalPayment(_) => { @@ -2440,6 +2461,15 @@ fn apply_parent_chain_context( state: &mut GameState, ) { child.context = parent.context.clone(); + // CR 701.9a + CR 608.2c: A discard result is visible only to the direct + // contingent child. Every ordinary hand-off clears it, preventing a later + // grandchild (or an unrelated chain branch) from reading stale provenance. + if !matches!( + parent.effect, + Effect::Discard { .. } | Effect::DiscardCard { .. } + ) { + child.context.direct_discard_result = None; + } // CR 401.5 + CR 608.2c (issue #1365) + CR 609.3 + issue #4950 // (Thoughtseize): `state.last_parent_target_missing_reason` is `Some` for // the narrow window between a Dig/ChooseFromZone/RevealHand reveal-choice @@ -2883,7 +2913,8 @@ fn quantity_ref_depends_on_zone_change_this_way(qty: &QuantityRef) -> bool { fn condition_depends_on_zone_change_this_way(condition: &AbilityCondition) -> bool { match condition { - AbilityCondition::ZoneChangedThisWay { .. } => true, + AbilityCondition::ZoneChangedThisWay { .. } + | AbilityCondition::DiscardedCardMatchesFilter { .. } => true, AbilityCondition::QuantityCheck { lhs, rhs, .. } => { quantity_expr_depends_on_zone_change_this_way(lhs) || quantity_expr_depends_on_zone_change_this_way(rhs) @@ -2942,13 +2973,13 @@ fn sub_ability_target_belongs_to_reflexive_context(sub: &ResolvedAbility) -> boo } } -fn condition_contains_city_blessing(condition: &AbilityCondition) -> bool { +fn condition_contains_designation(condition: &AbilityCondition) -> bool { match condition { - AbilityCondition::HasCityBlessing => true, - AbilityCondition::Not { condition } => condition_contains_city_blessing(condition), - AbilityCondition::ConditionInstead { inner } => condition_contains_city_blessing(inner), + AbilityCondition::HasCityBlessing | AbilityCondition::HasEnduringStory => true, + AbilityCondition::Not { condition } => condition_contains_designation(condition), + AbilityCondition::ConditionInstead { inner } => condition_contains_designation(inner), AbilityCondition::And { conditions } | AbilityCondition::Or { conditions } => { - conditions.iter().any(condition_contains_city_blessing) + conditions.iter().any(condition_contains_designation) } _ => false, } @@ -3239,6 +3270,8 @@ fn should_resolve_subability_on_optional_decline(ability: &ResolvedAbility) -> b | AbilityCondition::CompletedDungeon { .. } | AbilityCondition::IsInitiative | AbilityCondition::HasCityBlessing + | AbilityCondition::HasEnduringStory + | AbilityCondition::DiscardedCardMatchesFilter { .. } | AbilityCondition::IsRingBearer | AbilityCondition::TargetHasKeywordInstead { .. } | AbilityCondition::TargetMatchesFilter { .. } @@ -10119,6 +10152,56 @@ fn resolve_chain_body( _ => None, }) .collect(); + // CR 701.9a + CR 608.2c + CR 400.7: Capture the result from the active, + // operation-owned discard frame. Unlike an event-slice or global ledger, + // the frame remains exact through replacement redirection and cannot be + // satisfied by a sibling discard from the same source. + let mut discard_context; + let ability = if matches!( + ability.effect, + Effect::Discard { .. } | Effect::DiscardCard { .. } + ) { + let result = state.resolution_stack.active_discard().and_then(|frame| { + frame + .results + .first() + .map(|result| (frame.id, result.clone())) + }); + if let Some((frame_id, result)) = result { + discard_context = ability.clone(); + discard_context.context.direct_discard_result = Some(result); + let retired = state.resolution_stack.take_active_discard().expect( + "completed Recruit discard frame must remain live until direct-child hand-off", + ); + assert_eq!( + retired.expect("active Recruit frame must exist").id, + frame_id + ); + &discard_context + } else { + // A prevented/no-op Recruit discard cannot satisfy its contingent + // instruction. Once no prompt remains, retire the operation frame + // instead of letting it leak into a later discard resolution. + if matches!(state.waiting_for, WaitingFor::Priority { .. }) { + if let Some(frame_id) = state + .resolution_stack + .active_discard() + .map(|frame| frame.id) + { + let retired = state.resolution_stack.take_active_discard().expect( + "settled Recruit discard frame must remain live until its gate is checked", + ); + assert_eq!( + retired.expect("active Recruit frame must exist").id, + frame_id + ); + } + } + ability + } + } else { + ability + }; // CR 608.2c + CR 109.5: Accumulate player actions across the chain for // `PlayerFilter::PerformedActionThisWay`. This is distinct from // `last_zone_changed_ids`: "searched this way" keys off the player action @@ -10314,9 +10397,10 @@ fn resolve_chain_body( if ability.sub_ability.as_ref().is_some_and(|sub| { sub.condition .as_ref() - .is_some_and(condition_contains_city_blessing) + .is_some_and(condition_contains_designation) }) { crate::game::sba::apply_city_blessing_if_triggered(state, events); + crate::game::sba::apply_enduring_story_if_triggered(state, events); } // Follow typed sub_ability chain, propagating parent targets when sub has none. @@ -11841,6 +11925,23 @@ pub(crate) fn evaluate_condition( // CR 702.131c: The city's blessing is a player designation that effects // can identify. AbilityCondition::HasCityBlessing => eval_has_city_blessing(state, ability.controller), + AbilityCondition::HasEnduringStory => eval_has_enduring_story(state, ability.controller), + // CR 701.9a + CR 608.2c + CR 400.7: Recruit reads the captured + // hand-time characteristics of the card discarded by its direct parent; + // a redirect and any destination-zone incarnation are irrelevant. + AbilityCondition::DiscardedCardMatchesFilter { filter } => ability + .context + .direct_discard_result + .as_ref() + .is_some_and(|result| { + crate::game::filter::matches_target_filter_on_lki_snapshot( + state, + result.object_id, + &result.lki, + filter, + &crate::game::filter::FilterContext::from_ability(ability), + ) + }), // CR 701.54a: Ring-bearer designation on the ability source. AbilityCondition::IsRingBearer => crate::game::effects::ring::is_current_ring_bearer( state, @@ -23072,6 +23173,7 @@ mod tests { fn evaluate_condition_city_blessing_checks_ability_controller() { let mut state = GameState::new_two_player(42); state.city_blessing.insert(PlayerId(0)); + state.enduring_story.insert(PlayerId(0)); let ability = ResolvedAbility::new( Effect::Draw { count: QuantityExpr::Fixed { value: 1 }, @@ -23087,6 +23189,11 @@ mod tests { &state, &ability, )); + assert!(evaluate_condition( + &AbilityCondition::HasEnduringStory, + &state, + &ability, + )); let opponent_ability = ResolvedAbility::new( Effect::Draw { @@ -23102,6 +23209,11 @@ mod tests { &state, &opponent_ability, )); + assert!(!evaluate_condition( + &AbilityCondition::HasEnduringStory, + &state, + &opponent_ability, + )); } /// CR 115.1 + CR 608.2c + CR 702.33d: `AdditionalCostPaid { subject }` reads @@ -26671,15 +26783,15 @@ mod tests { } #[test] - fn condition_contains_city_blessing_recurses_through_condition_instead() { + fn condition_contains_designation_recurses_through_condition_instead() { let condition = AbilityCondition::ConditionInstead { inner: Box::new(AbilityCondition::HasCityBlessing), }; assert!( - condition_contains_city_blessing(&condition), - "city's-blessing gated continuations wrapped in ConditionInstead must run the \ - mid-chain blessing check before the condition is evaluated" + condition_contains_designation(&condition), + "designation-gated continuations wrapped in ConditionInstead must run the \ + mid-chain designation check before the condition is evaluated" ); } diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 8870e136e7..3adc387a86 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -15668,9 +15668,9 @@ mod stage2_injector_tests { // because that is what makes a NEW mint a counted event; a function + // content-hash anchor would end the drift class while keeping that property, // and is offered as a follow-up rather than taken unannounced mid-review. - "game/effects/mod.rs:6177".to_string(), - "game/effects/mod.rs:6254".to_string(), - "game/effects/mod.rs:9442".to_string(), + "game/effects/mod.rs:6210".to_string(), + "game/effects/mod.rs:6287".to_string(), + "game/effects/mod.rs:9475".to_string(), // UNMOVED across the rebase, and that is itself evidence the SET did not // move: a census that had gained or lost a producer would not leave this // entry both byte-identical AND at the same coordinate. diff --git a/crates/engine/src/game/engine_debug.rs b/crates/engine/src/game/engine_debug.rs index c6acf85ee8..163772e22b 100644 --- a/crates/engine/src/game/engine_debug.rs +++ b/crates/engine/src/game/engine_debug.rs @@ -772,6 +772,7 @@ pub fn route_debug_create_to_battlefield( enter_transformed: false, face_down_profile: None, enter_as_copy: None, + discard_frame: None, applied: HashSet::new(), }; diff --git a/crates/engine/src/game/engine_payment_choices.rs b/crates/engine/src/game/engine_payment_choices.rs index 5083c223c9..485b7d9c47 100644 --- a/crates/engine/src/game/engine_payment_choices.rs +++ b/crates/engine/src/game/engine_payment_choices.rs @@ -1569,6 +1569,7 @@ pub(super) fn handle_ward_discard_choice( chosen[0], player, Some(pending_effect.source_id), + None, std::collections::HashSet::new(), events, ) diff --git a/crates/engine/src/game/engine_replacement.rs b/crates/engine/src/game/engine_replacement.rs index 78009e51f4..2c085f1502 100644 --- a/crates/engine/src/game/engine_replacement.rs +++ b/crates/engine/src/game/engine_replacement.rs @@ -246,13 +246,14 @@ pub(super) fn handle_replacement_choice( // is restored structurally inside the shared delivery for // `Stack → Battlefield` events (`CastLinkSnapshot`). event @ ProposedEvent::ZoneChange { .. } => { - let (object_id, to, cause) = match &event { + let (object_id, to, cause, discard_frame) = match &event { ProposedEvent::ZoneChange { object_id, to, cause, + discard_frame, .. - } => (*object_id, *to, *cause), + } => (*object_id, *to, *cause, *discard_frame), _ => unreachable!("arm pattern guarantees ZoneChange"), }; let Ok(approved) = @@ -324,6 +325,13 @@ pub(super) fn handle_replacement_choice( }); } } + // CR 701.9a + CR 616.1: a discard move that paused for + // replacement ordering reaches terminal delivery here. + // Restore its exact continuation data before the common + // epilogue drains the parked ability chain. + if let Some(frame_id) = discard_frame { + effects::discard::hand_off_recruit_discard_result(state, frame_id); + } enters_battlefield = to == Zone::Battlefield; zone_change_object_id = Some(object_id); } @@ -613,12 +621,19 @@ pub(super) fn handle_replacement_choice( player_id, object_id, source_id, + discard_frame, applied, .. } => { if let effects::discard::DiscardOutcome::NeedsReplacementChoice(player) = effects::discard::complete_discard_to_graveyard( - state, object_id, player_id, source_id, applied, events, + state, + object_id, + player_id, + source_id, + discard_frame, + applied, + events, ) { state.waiting_for = @@ -4127,6 +4142,7 @@ mod tests { controller_override: None, enter_transformed: false, enter_as_copy: None, + discard_frame: None, applied: std::collections::HashSet::new(), face_down_profile: None, }; @@ -6044,6 +6060,7 @@ mod tests { controller_override: None, enter_transformed: false, enter_as_copy: None, + discard_frame: None, applied: std::collections::HashSet::new(), face_down_profile: None, }; @@ -6248,6 +6265,7 @@ mod tests { controller_override: None, enter_transformed: false, enter_as_copy: None, + discard_frame: None, applied: std::collections::HashSet::new(), face_down_profile: None, }; @@ -6368,6 +6386,7 @@ mod tests { controller_override: None, enter_transformed: false, enter_as_copy: None, + discard_frame: None, applied: std::collections::HashSet::new(), face_down_profile: None, }; @@ -6682,6 +6701,7 @@ mod tests { controller_override: None, enter_transformed: false, enter_as_copy: None, + discard_frame: None, applied: std::collections::HashSet::new(), face_down_profile: None, }; diff --git a/crates/engine/src/game/engine_resolution_choices.rs b/crates/engine/src/game/engine_resolution_choices.rs index 00b4818ed1..95dad02826 100644 --- a/crates/engine/src/game/engine_resolution_choices.rs +++ b/crates/engine/src/game/engine_resolution_choices.rs @@ -4524,6 +4524,7 @@ pub(super) fn handle_resolution_choice( effect_kind, up_to, unless_filter, + discard_frame, }, GameAction::SelectCards { cards: chosen }, ) => { @@ -4579,11 +4580,12 @@ pub(super) fn handle_resolution_choice( let events_before_effect = events.len(); for &card_id in &chosen { if let effects::discard::DiscardOutcome::NeedsReplacementChoice(choice_player) = - effects::discard::discard_caused_by_effect_with_source( + effects::discard::discard_caused_by_effect_with_source_and_frame( state, card_id, player, Some(source_id), + discard_frame, events, ) { @@ -4653,6 +4655,15 @@ pub(super) fn handle_resolution_choice( } } + // CR 701.9a + CR 608.2c: A Recruit discard that paused for card + // selection now has its terminal LKI result in the operation-owned + // frame. Stamp that result only onto the deferred direct child before + // the continuation drains; the ordinary parent→child hand-off clears + // it again for grandchildren. + if let Some(frame_id) = discard_frame { + effects::discard::hand_off_recruit_discard_result(state, frame_id); + } + // CR 608.2c + CR 400.7j: A reflexive sub deferred across this // interactive discard may name the discarded card anaphorically — // "When you discard a card this way, target player mills cards equal diff --git a/crates/engine/src/game/engine_tests.rs b/crates/engine/src/game/engine_tests.rs index ad5ea695f6..ab7be8f430 100644 --- a/crates/engine/src/game/engine_tests.rs +++ b/crates/engine/src/game/engine_tests.rs @@ -358,6 +358,7 @@ fn terminal_reconcile_does_not_run_sbas_for_cant_lose_player() { effect_kind: EffectKind::DiscardCard, up_to: false, unless_filter: None, + discard_frame: None, }; let original_waiting_for = state.waiting_for.clone(); let mut result = ActionResult { @@ -386,6 +387,7 @@ fn terminal_reconcile_runs_player_loss_sba_for_unprotected_player() { effect_kind: EffectKind::DiscardCard, up_to: false, unless_filter: None, + discard_frame: None, }; let mut result = ActionResult { events: Vec::new(), diff --git a/crates/engine/src/game/gap_analysis.rs b/crates/engine/src/game/gap_analysis.rs index 1a0b6ff46d..48ac7a16c1 100644 --- a/crates/engine/src/game/gap_analysis.rs +++ b/crates/engine/src/game/gap_analysis.rs @@ -53,6 +53,7 @@ const IMPERATIVE_EXTRA_VERBS: &[&str] = &[ "populate", "clash", "planeswalk", + "recruit", ]; /// Pre-dispatch verbs handled in `parse_effect_clause` before imperative dispatch. diff --git a/crates/engine/src/game/layers.rs b/crates/engine/src/game/layers.rs index a8f2e124c3..b58d2c33d6 100644 --- a/crates/engine/src/game/layers.rs +++ b/crates/engine/src/game/layers.rs @@ -5,10 +5,10 @@ use crate::database::synthesis::KeywordTriggerInstaller; use crate::game::arithmetic::saturating_pt_add; use crate::game::conditions::{ counter_condition_matches, eval_chosen_label_is, eval_class_level_ge, eval_has_city_blessing, - eval_is_initiative, eval_is_monarch, eval_no_monarch, eval_recipient_attacking_owner_target, - eval_shares_color_with_most_common_color, eval_source_entered_this_turn, - eval_source_has_dealt_damage, eval_source_in_zone, eval_source_is_attacking, - eval_source_is_tapped_on_battlefield, + eval_has_enduring_story, eval_is_initiative, eval_is_monarch, eval_no_monarch, + eval_recipient_attacking_owner_target, eval_shares_color_with_most_common_color, + eval_source_entered_this_turn, eval_source_has_dealt_damage, eval_source_in_zone, + eval_source_is_attacking, eval_source_is_tapped_on_battlefield, }; use crate::game::devotion::count_devotion; use crate::game::filter::{ @@ -1093,6 +1093,7 @@ fn static_condition_uses_object_population(condition: &StaticCondition) -> bool | StaticCondition::IsInitiative | StaticCondition::NoMonarch | StaticCondition::HasCityBlessing + | StaticCondition::HasEnduringStory | StaticCondition::CompletedADungeon | StaticCondition::WasStartingPlayer { .. } | StaticCondition::SpellCastWithVariantThisTurn { .. } @@ -1249,6 +1250,7 @@ fn static_condition_characteristic_reads_at( | StaticCondition::IsInitiative | StaticCondition::NoMonarch | StaticCondition::HasCityBlessing + | StaticCondition::HasEnduringStory | StaticCondition::CompletedADungeon | StaticCondition::WasStartingPlayer { .. } | StaticCondition::SpellCastWithVariantThisTurn { .. } @@ -1372,6 +1374,7 @@ fn entered_object_perturbs_static_condition( | StaticCondition::IsInitiative | StaticCondition::NoMonarch | StaticCondition::HasCityBlessing + | StaticCondition::HasEnduringStory | StaticCondition::CompletedADungeon | StaticCondition::WasStartingPlayer { .. } | StaticCondition::SpellCastWithVariantThisTurn { .. } @@ -1822,6 +1825,8 @@ fn evaluate_condition_with_context( StaticCondition::NoMonarch => eval_no_monarch(state), // CR 702.131a: True when the controller has the city's blessing. StaticCondition::HasCityBlessing => eval_has_city_blessing(state, controller), + // CR 702.195b: True when the controller has the enduring story designation. + StaticCondition::HasEnduringStory => eval_has_enduring_story(state, controller), StaticCondition::OpponentPoisonAtLeast { count } => state .players .iter() @@ -3445,6 +3450,7 @@ fn static_condition_reads_life(condition: &StaticCondition) -> bool { | StaticCondition::IsInitiative | StaticCondition::NoMonarch | StaticCondition::HasCityBlessing + | StaticCondition::HasEnduringStory | StaticCondition::CompletedADungeon | StaticCondition::WasStartingPlayer { .. } | StaticCondition::SpellCastWithVariantThisTurn { .. } diff --git a/crates/engine/src/game/log.rs b/crates/engine/src/game/log.rs index d620028f50..42618f87fa 100644 --- a/crates/engine/src/game/log.rs +++ b/crates/engine/src/game/log.rs @@ -228,6 +228,7 @@ fn categorize(event: &GameEvent) -> LogCategory { GameEvent::MonarchChanged { .. } | GameEvent::CityBlessingGained { .. } + | GameEvent::EnduringStoryGained { .. } | GameEvent::DieRolled { .. } | GameEvent::CoinFlipped { .. } | GameEvent::RingTemptsYou { .. } @@ -973,6 +974,13 @@ fn format_segments(event: &GameEvent, state: &GameState) -> Vec { ] } + GameEvent::EnduringStoryGained { player_id } => { + vec![ + player_seg(state, *player_id), + text(" gains an enduring story"), + ] + } + GameEvent::DieRolled { player_id, sides, diff --git a/crates/engine/src/game/public_state.rs b/crates/engine/src/game/public_state.rs index 9b1e591316..0ad3146231 100644 --- a/crates/engine/src/game/public_state.rs +++ b/crates/engine/src/game/public_state.rs @@ -358,6 +358,7 @@ pub fn mark_public_state_from_events(state: &mut GameState, events: &[GameEvent] } GameEvent::MonarchChanged { player_id } | GameEvent::CityBlessingGained { player_id } + | GameEvent::EnduringStoryGained { player_id } | GameEvent::InitiativeTaken { player_id } | GameEvent::AttractionOpened { player_id, .. } | GameEvent::ContraptionAssembled { player_id, .. } diff --git a/crates/engine/src/game/quantity.rs b/crates/engine/src/game/quantity.rs index 00515ffc01..eb247bb8b7 100644 --- a/crates/engine/src/game/quantity.rs +++ b/crates/engine/src/game/quantity.rs @@ -831,6 +831,7 @@ pub(crate) fn static_condition_uses_unspent_mana(condition: &StaticCondition) -> | StaticCondition::IsInitiative | StaticCondition::NoMonarch | StaticCondition::HasCityBlessing + | StaticCondition::HasEnduringStory | StaticCondition::CompletedADungeon | StaticCondition::WasStartingPlayer { .. } | StaticCondition::SpellCastWithVariantThisTurn { .. } diff --git a/crates/engine/src/game/replacement.rs b/crates/engine/src/game/replacement.rs index fd40aa4a1b..d4b5a8b982 100644 --- a/crates/engine/src/game/replacement.rs +++ b/crates/engine/src/game/replacement.rs @@ -1647,7 +1647,10 @@ fn discard_applier( ) -> ApplyResult { match event { ProposedEvent::Discard { - object_id, applied, .. + object_id, + discard_frame, + applied, + .. } => ApplyResult::Modified(ProposedEvent::ZoneChange { object_id, from: Zone::Hand, @@ -1660,6 +1663,7 @@ fn discard_applier( enter_transformed: false, face_down_profile: None, enter_as_copy: None, + discard_frame, applied, }), other => ApplyResult::Modified(other), @@ -10347,6 +10351,7 @@ mod tests { object_id: ObjectId(10), source_id: None, caused_by_effect: false, + discard_frame: None, applied: HashSet::new(), }, &mut events, @@ -10805,6 +10810,7 @@ mod tests { object_id: ObjectId(1), source_id: None, caused_by_effect: false, + discard_frame: None, applied: HashSet::new(), }, vec![ReplacementEvent::Discard], @@ -11131,6 +11137,7 @@ mod tests { controller_override: None, enter_transformed: false, enter_as_copy: None, + discard_frame: None, applied: HashSet::new(), face_down_profile: None, }; @@ -13349,6 +13356,7 @@ mod tests { controller_override: None, enter_transformed: false, enter_as_copy: None, + discard_frame: None, applied: HashSet::new(), face_down_profile: None, }; @@ -14449,6 +14457,7 @@ mod tests { controller_override: None, enter_transformed: false, enter_as_copy: None, + discard_frame: None, applied: HashSet::new(), face_down_profile: None, }; @@ -17600,6 +17609,7 @@ mod tests { enter_transformed: false, enter_as_copy: None, face_down_profile: None, + discard_frame: None, applied: HashSet::new(), }; let cast_matches = find_applicable_replacements(&state, &cast_event, ®istry); @@ -17645,6 +17655,7 @@ mod tests { enter_transformed: false, enter_as_copy: None, face_down_profile: None, + discard_frame: None, applied: HashSet::new(), }; let put_matches = find_applicable_replacements(&state, &put_event, ®istry); diff --git a/crates/engine/src/game/restrictions.rs b/crates/engine/src/game/restrictions.rs index 13a5922a83..bc88b06e83 100644 --- a/crates/engine/src/game/restrictions.rs +++ b/crates/engine/src/game/restrictions.rs @@ -1635,6 +1635,9 @@ pub(crate) fn evaluate_condition( // CR 702.131c: The city's blessing is a player designation that effects // and restrictions may identify. ParsedCondition::HasCityBlessing => state.city_blessing.contains(&player), + // CR 702.195b: The enduring story is a player designation effects and + // restrictions may identify. + ParsedCondition::HasEnduringStory => state.enduring_story.contains(&player), // CR 903.3 / CR 903.3d: owner-scoped ("your commander") vs any-owner ("a // commander") control. Delegates to the single `game::commander` authority — // the same helpers `layers.rs` uses for `StaticCondition::ControlsCommander` — @@ -2443,6 +2446,18 @@ mod tests { assert!(evaluate_condition(&state, player, source_id, &condition)); } + #[test] + fn enduring_story_restriction_checks_player_designation() { + let mut state = crate::types::game_state::GameState::new_two_player(42); + let player = PlayerId(0); + let source_id = ObjectId(10); + let condition = ParsedCondition::HasEnduringStory; + + assert!(!evaluate_condition(&state, player, source_id, &condition)); + state.enduring_story.insert(player); + assert!(evaluate_condition(&state, player, source_id, &condition)); + } + #[test] fn land_played_restriction_checks_player_land_count() { let mut state = crate::types::game_state::GameState::new_two_player(42); diff --git a/crates/engine/src/game/sba.rs b/crates/engine/src/game/sba.rs index 2a6f21deb0..f6daedbe57 100644 --- a/crates/engine/src/game/sba.rs +++ b/crates/engine/src/game/sba.rs @@ -7,7 +7,7 @@ use crate::game::zone_pipeline::{ self, ApprovedZoneChange, DeliveryCtx, ExileLinkSpec, ZoneDeliveryResult, ZoneMoveRequest, ZoneMoveResult, }; -use crate::types::ability::{ControllerRef, TargetFilter, TypedFilter}; +use crate::types::ability::{ControllerRef, FilterProp, TargetFilter, TypedFilter}; use crate::types::card_type::{CoreType, Supertype}; use crate::types::counter::CounterType; use crate::types::events::GameEvent; @@ -18,6 +18,7 @@ use crate::types::proposed_event::ProposedEvent; use crate::types::statics::{StaticMode, StaticModeKind}; use crate::types::zones::Zone; +use super::filter::{matches_target_filter, FilterContext}; use super::speed::{controls_start_your_engines_in, set_speed}; use super::zones; @@ -265,6 +266,9 @@ pub fn check_state_based_actions(state: &mut GameState, events: &mut Vec, + any_performed: &mut bool, + battlefield_snapshot: &[ObjectId], +) { + let historic_filter = + TargetFilter::Typed(TypedFilter::permanent().properties(vec![FilterProp::Historic])); + let players_to_designate: Vec = state + .players + .iter() + .map(|player| player.id) + .filter(|player| !state.enduring_story.contains(player)) + .filter(|player| { + let permanents: Vec = battlefield_snapshot + .iter() + .copied() + .filter(|id| { + live_battlefield_object(state, id).is_some_and(|obj| obj.controller == *player) + }) + .collect(); + let context = FilterContext::neutral(); + permanents.iter().any(|id| { + state + .objects + .get(id) + .is_some_and(|obj| obj.has_keyword(&crate::types::keywords::Keyword::Storied)) + }) && permanents + .iter() + .filter(|id| matches_target_filter(state, **id, &historic_filter, &context)) + .count() + >= 3 + }) + .collect(); + + for player_id in players_to_designate { + state.enduring_story.insert(player_id); + crate::game::layers::mark_layers_full(state); + events.push(GameEvent::EnduringStoryGained { player_id }); + *any_performed = true; + } +} + /// CR 702.131b + CR 702.131d: Eagerly re-evaluate the city's blessing for all /// players outside the normal SBA loop. Called from `resolve_chain_body` after /// a parent effect resolves and before a `HasCityBlessing`-gated sub-ability @@ -377,6 +426,18 @@ pub(crate) fn apply_city_blessing_if_triggered(state: &mut GameState, events: &m } } +pub(crate) fn apply_enduring_story_if_triggered( + state: &mut GameState, + events: &mut Vec, +) { + let mut any_performed = false; + let battlefield = state.battlefield_phased_in_ids(); + check_enduring_story(state, events, &mut any_performed, &battlefield); + if any_performed { + crate::game::layers::flush_layers(state); + } +} + fn check_city_blessing_eager( state: &mut GameState, events: &mut Vec, @@ -5394,6 +5455,119 @@ mod tests { create_creature(state, CardId(9002), owner, name, 1, 1) } + fn add_storied_permanent(state: &mut GameState, owner: PlayerId) -> ObjectId { + let id = create_creature(state, CardId(9003), owner, "Storied", 1, 1); + state + .objects + .get_mut(&id) + .unwrap() + .keywords + .push(crate::types::keywords::Keyword::Storied); + id + } + + #[test] + fn storied_grants_enduring_story_for_each_historic_leg_and_latches() { + let mut state = setup(); + add_storied_permanent(&mut state, PlayerId(0)); + + let legendary = add_filler_permanent(&mut state, PlayerId(0), "Legendary"); + state + .objects + .get_mut(&legendary) + .unwrap() + .card_types + .supertypes + .push(Supertype::Legendary); + let artifact = add_filler_permanent(&mut state, PlayerId(0), "Artifact"); + state + .objects + .get_mut(&artifact) + .unwrap() + .card_types + .core_types + .push(CoreType::Artifact); + let saga = add_filler_permanent(&mut state, PlayerId(0), "Saga"); + state + .objects + .get_mut(&saga) + .unwrap() + .card_types + .subtypes + .push("Saga".into()); + + let mut events = Vec::new(); + check_state_based_actions(&mut state, &mut events); + assert!(state.enduring_story.contains(&PlayerId(0))); + assert!(events.iter().any(|event| matches!( + event, + GameEvent::EnduringStoryGained { + player_id: PlayerId(0) + } + ))); + + state + .battlefield + .retain(|id| *id != legendary && *id != artifact && *id != saga); + let mut later_events = Vec::new(); + check_state_based_actions(&mut state, &mut later_events); + assert!(state.enduring_story.contains(&PlayerId(0))); + } + + #[test] + fn storied_requires_live_storied_and_three_live_historic_permanents() { + let mut state = setup(); + let storied = add_storied_permanent(&mut state, PlayerId(0)); + for index in 0..3 { + let historic = + add_filler_permanent(&mut state, PlayerId(0), &format!("Historic{index}")); + state + .objects + .get_mut(&historic) + .unwrap() + .card_types + .core_types + .push(CoreType::Artifact); + } + state.objects.get_mut(&storied).unwrap().phase_status = + crate::game::game_object::PhaseStatus::PhasedOut { + cause: crate::game::game_object::PhaseOutCause::Directly, + }; + + let mut events = Vec::new(); + check_state_based_actions(&mut state, &mut events); + assert!(!state.enduring_story.contains(&PlayerId(0))); + + state.objects.get_mut(&storied).unwrap().phase_status = + crate::game::game_object::PhaseStatus::PhasedIn; + state.battlefield.pop_back(); + check_state_based_actions(&mut state, &mut events); + assert!(!state.enduring_story.contains(&PlayerId(0))); + } + + #[test] + fn storied_requires_a_storied_permanent() { + let mut state = setup(); + for index in 0..3 { + let historic = + add_filler_permanent(&mut state, PlayerId(0), &format!("Historic{index}")); + state + .objects + .get_mut(&historic) + .unwrap() + .card_types + .core_types + .push(CoreType::Artifact); + } + + let mut events = Vec::new(); + check_state_based_actions(&mut state, &mut events); + assert!(!state.enduring_story.contains(&PlayerId(0))); + assert!(!events + .iter() + .any(|event| matches!(event, GameEvent::EnduringStoryGained { .. }))); + } + #[test] fn ascend_nine_permanents_no_blessing() { let mut state = setup(); diff --git a/crates/engine/src/game/trigger_index.rs b/crates/engine/src/game/trigger_index.rs index 57cc862abe..643d7b7ec8 100644 --- a/crates/engine/src/game/trigger_index.rs +++ b/crates/engine/src/game/trigger_index.rs @@ -666,7 +666,9 @@ pub(crate) fn keys_from_event(event: &GameEvent, state: &GameState) -> Keys { push(TriggerEventKey::DungeonOrClassOrCase); } GameEvent::MonarchChanged { .. } => push(TriggerEventKey::MonarchOrInitiative), - GameEvent::CityBlessingGained { .. } => {} + // CR 702.195b-c: Enduring story is a designation, not an inherent trigger + // event; continuous effects reapply before trigger conditions are checked. + GameEvent::CityBlessingGained { .. } | GameEvent::EnduringStoryGained { .. } => {} // CR 103.1: setup determination, not a CR 706 die-roll trigger source. GameEvent::StartingPlayerContest { .. } => {} GameEvent::DieRolled { .. } | GameEvent::CoinFlipped { .. } => { diff --git a/crates/engine/src/game/trigger_matchers.rs b/crates/engine/src/game/trigger_matchers.rs index c02a42cb8e..7d8baf5c36 100644 --- a/crates/engine/src/game/trigger_matchers.rs +++ b/crates/engine/src/game/trigger_matchers.rs @@ -1022,6 +1022,7 @@ fn count_matching_trigger_event_subjects( | GameEvent::ClassLevelGained { .. } | GameEvent::MonarchChanged { .. } | GameEvent::CityBlessingGained { .. } + | GameEvent::EnduringStoryGained { .. } | GameEvent::DieRolled { .. } | GameEvent::CoinFlipped { .. } | GameEvent::RingTemptsYou { .. } diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index d079946901..5ecc34534a 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -44,8 +44,8 @@ use crate::types::zones::Zone; use super::ability_utils::build_resolved_from_def; use super::conditions::{ - counter_condition_matches_lki, eval_has_city_blessing, eval_is_initiative, eval_is_monarch, - eval_no_monarch, eval_source_is_attacking, + counter_condition_matches_lki, eval_has_city_blessing, eval_has_enduring_story, + eval_is_initiative, eval_is_monarch, eval_no_monarch, eval_source_is_attacking, }; use super::filter::{ matches_target_filter, matches_target_filter_on_damage_record_source, @@ -10361,6 +10361,8 @@ fn evaluate_trigger_condition_with_source( TriggerCondition::NoMonarch => eval_no_monarch(state), // CR 702.131a: True when the controller has the city's blessing. TriggerCondition::HasCityBlessing => eval_has_city_blessing(state, controller), + // CR 702.195b: True when the controller has the enduring story designation. + TriggerCondition::HasEnduringStory => eval_has_enduring_story(state, controller), // CR 110.5b: True when the trigger source is tapped. Negation ("untapped") // wraps via `Not { Box::new(SourceIsTapped) }`. No battlefield zone guard // (trigger conditions; zone already constrained by functioning-abilities path). @@ -31267,6 +31269,7 @@ pub mod tests { | Keyword::Exploit | Keyword::Explore | Keyword::Ascend + | Keyword::Storied | Keyword::StartYourEngines | Keyword::Dredge(_) | Keyword::Modular(_) diff --git a/crates/engine/src/game/zone_pipeline.rs b/crates/engine/src/game/zone_pipeline.rs index 9871652876..8043b36e17 100644 --- a/crates/engine/src/game/zone_pipeline.rs +++ b/crates/engine/src/game/zone_pipeline.rs @@ -2158,6 +2158,7 @@ pub(crate) fn deliver_replaced_zone_change( controller_override: ctrl_override, face_down_profile, enter_as_copy, + discard_frame, applied, .. } = event @@ -2170,6 +2171,19 @@ pub(crate) fn deliver_replaced_zone_change( } else { ZoneDeliveryExileTracking::None }; + // CR 701.9a + CR 400.7: Capture the card while it is still in hand. + // The discard frame, not a current-zone lookup, owns the eventual + // contingent condition's facts through redirects and replacement pauses. + let discard_lki = discard_frame.and_then(|_| { + (from == Zone::Hand) + .then(|| { + state + .objects + .get(&object_id) + .map(|object| object.snapshot_for_mana_spent()) + }) + .flatten() + }); let merged_permanent_leave = from == Zone::Battlefield && state @@ -2381,6 +2395,47 @@ pub(crate) fn deliver_replaced_zone_change( .objects .get(&object_id) .is_some_and(|obj| obj.zone == Zone::Battlefield); + // CR 701.9a + CR 614.1: The inner move has now completed with its + // final replacement-selected destination. Append one operation-owned + // result exactly once; a prevented move never reaches this delivery. + if let (Some(frame_id), Some(lki), Some(final_zone)) = ( + discard_frame, + discard_lki, + state.objects.get(&object_id).map(|object| object.zone), + ) { + if final_zone != Zone::Hand { + let (recorded, source_id) = { + let frame = state + .resolution_stack + .active_discard_mut() + .filter(|frame| frame.id == frame_id) + .expect("discard provenance must name the active discard frame"); + let recorded = frame.results.is_empty(); + let source_id = frame.source_id; + if recorded { + frame + .results + .push(crate::types::ability::DiscardedCardResult { + object_id, + lki: lki.clone(), + final_zone, + }); + } + (recorded, source_id) + }; + if recorded { + crate::game::restrictions::record_discard(state, lki.owner); + if final_zone == Zone::Graveyard { + crate::game::restrictions::record_card_discarded(state, object_id); + } + events.push(GameEvent::Discarded { + player_id: lki.owner, + object_id, + source_id, + }); + } + } + } // Roll back the face-down preflight flag when the entry was rejected, so a // blocked manifest/morph leaves the card unchanged in its origin zone // rather than stranded face down (corrupting hidden state for a move that diff --git a/crates/engine/src/parser/oracle_condition.rs b/crates/engine/src/parser/oracle_condition.rs index 0a6033405b..f2c8ca39a0 100644 --- a/crates/engine/src/parser/oracle_condition.rs +++ b/crates/engine/src/parser/oracle_condition.rs @@ -298,6 +298,8 @@ fn static_condition_to_restriction_condition( }), // Player-state leaves with an exact restriction evaluator. StaticCondition::HasCityBlessing => Some(ParsedCondition::HasCityBlessing), + // CR 702.195b: The enduring story designation is available to restrictions. + StaticCondition::HasEnduringStory => Some(ParsedCondition::HasEnduringStory), StaticCondition::OpponentPoisonAtLeast { count } => { Some(ParsedCondition::OpponentPoisonAtLeast { count }) } diff --git a/crates/engine/src/parser/oracle_effect/conditions.rs b/crates/engine/src/parser/oracle_effect/conditions.rs index a8d40deb4c..cf45a7aaee 100644 --- a/crates/engine/src/parser/oracle_effect/conditions.rs +++ b/crates/engine/src/parser/oracle_effect/conditions.rs @@ -4531,6 +4531,8 @@ pub(crate) fn static_condition_to_ability_condition( StaticCondition::IsMonarch => Some(AbilityCondition::IsMonarch), StaticCondition::IsInitiative => Some(AbilityCondition::IsInitiative), StaticCondition::HasCityBlessing => Some(AbilityCondition::HasCityBlessing), + // CR 702.195b: The enduring story designation is available to effects. + StaticCondition::HasEnduringStory => Some(AbilityCondition::HasEnduringStory), StaticCondition::IsRingBearer => Some(AbilityCondition::IsRingBearer), StaticCondition::OpponentPoisonAtLeast { count } => { Some(opponent_poison_at_least_as_quantity_check(*count)) @@ -4925,6 +4927,7 @@ pub(crate) fn ability_condition_to_static_condition( | AbilityCondition::ConditionInstead { .. } | AbilityCondition::NthResolutionThisTurn { .. } | AbilityCondition::ScopedPlayerMatches { .. } => None, + AbilityCondition::DiscardedCardMatchesFilter { .. } => None, // No `StaticCondition` counterpart exists for these game-state // predicates. @@ -4943,6 +4946,7 @@ pub(crate) fn ability_condition_to_static_condition( | AbilityCondition::IsMonarch | AbilityCondition::IsInitiative | AbilityCondition::HasCityBlessing + | AbilityCondition::HasEnduringStory | AbilityCondition::IsRingBearer | AbilityCondition::WasStartingPlayer { .. } | AbilityCondition::SpellCastWithVariantThisTurn { .. } diff --git a/crates/engine/src/parser/oracle_effect/imperative.rs b/crates/engine/src/parser/oracle_effect/imperative.rs index a65fc3fcc3..5b65cd4be5 100644 --- a/crates/engine/src/parser/oracle_effect/imperative.rs +++ b/crates/engine/src/parser/oracle_effect/imperative.rs @@ -33,14 +33,14 @@ use crate::parser::oracle_static::{ parse_quoted_ability_modifications, }; use crate::types::ability::{ - AbilityCost, AbilityDefinition, AbilityKind, BounceSelection, CardSelectionMode, - CategoryChooserScope, ChoiceType, Chooser, ContinuousModification, ControlWindow, - ControllerRef, CopyRetargetPermission, CounterAdjustment, DigSource, DoorLockOp, Duration, - Effect, EffectScope, FaceDownProfile, FilterProp, ForceBlockAttackerRef, GrantedAbilityScope, - LibraryPosition, MultiTargetSpec, OutsideGameSourcePool, PlayerScope, PreventionAmount, - PreventionScope, PtStat, PtValue, QuantityExpr, QuantityRef, ReassembleControlMode, - SearchSelectionConstraint, StaticDefinition, StickerTicketCostPayment, TapStateChange, - TargetFilter, TargetSelectionMode, TypeFilter, TypedFilter, ZoneOwner, + AbilityCondition, AbilityCost, AbilityDefinition, AbilityKind, BounceSelection, + CardSelectionMode, CategoryChooserScope, ChoiceType, Chooser, ContinuousModification, + ControlWindow, ControllerRef, CopyRetargetPermission, CounterAdjustment, DigSource, DoorLockOp, + Duration, Effect, EffectScope, FaceDownProfile, FilterProp, ForceBlockAttackerRef, + GrantedAbilityScope, LibraryPosition, MultiTargetSpec, OutsideGameSourcePool, PlayerScope, + PreventionAmount, PreventionScope, PtStat, PtValue, QuantityExpr, QuantityRef, + ReassembleControlMode, SearchSelectionConstraint, StaticDefinition, StickerTicketCostPayment, + TapStateChange, TargetFilter, TargetSelectionMode, TypeFilter, TypedFilter, ZoneOwner, }; use crate::types::card_type::CoreType; use crate::types::phase::Phase; @@ -9746,6 +9746,19 @@ pub(super) fn parse_imperative_family_ast( return Some(ast); } + // CR 701.9a + CR 608.2c: Recruit is a standalone keyword action. Keep this + // an anchored nom production so `recruiter` and compound text do not become + // an accidental Recruit instruction. + if all_consuming(terminated( + tag::<_, _, OracleError<'_>>("recruit"), + opt(tag(".")), + )) + .parse(lower.trim()) + .is_ok() + { + return Some(ImperativeFamilyAst::Recruit); + } + // CR 724.1: "end the turn" (Time Stop, Sundial of the Infinite, Obeka, // Glorious End, Discontinuity, Day's Undoing). Whole-phrase imperative // with no target; parse it as an anchored nom production rather than a @@ -11845,6 +11858,56 @@ pub(crate) fn try_parse_reflexive_coin_flip_branch<'a>( pub(super) fn lower_imperative_family_ast(ast: ImperativeFamilyAst) -> ParsedEffectClause { match ast { + // CR 701.9a + CR 608.2c: Recruit's contingent token sees exactly the + // immediately preceding discard result. The typed condition is carried + // by the direct child, so no later chain step can consume stale discard + // provenance. + ImperativeFamilyAst::Recruit => { + let nonland = TargetFilter::Not { + filter: Box::new(TargetFilter::Typed(TypedFilter::land())), + }; + let token = AbilityDefinition::new( + AbilityKind::Spell, + Effect::Token { + name: "Human Soldier".to_string(), + power: PtValue::Fixed(1), + toughness: PtValue::Fixed(1), + types: vec![ + "Creature".to_string(), + "Human".to_string(), + "Soldier".to_string(), + ], + colors: vec![crate::types::mana::ManaColor::White], + keywords: Vec::new(), + tapped: false, + count: QuantityExpr::Fixed { value: 1 }, + owner: TargetFilter::Controller, + attach_to: None, + enters_attacking: false, + supertypes: Vec::new(), + static_abilities: Vec::new(), + enter_with_counters: Vec::new(), + }, + ) + .condition(AbilityCondition::DiscardedCardMatchesFilter { filter: nonland }); + let discard = AbilityDefinition::new( + AbilityKind::Spell, + Effect::Discard { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + selection: CardSelectionMode::Chosen, + unless_filter: None, + filter: None, + }, + ) + .sub_ability(token); + let mut clause = parsed_clause(Effect::Draw { + count: QuantityExpr::Fixed { value: 1 }, + target: TargetFilter::Controller, + }); + clause.sub_ability = Some(Box::new(discard)); + clause + } // CR 118.12: A Counter with an "unless [player] pays [cost]" modifier // — intercepted here so the modifier propagates to // `ParsedEffectClause.unless_pay`. The Effect itself becomes the @@ -12543,6 +12606,9 @@ fn lower_imperative_family_effect(ast: ImperativeFamilyAst) -> Effect { target: TargetFilter::Any, count: QuantityExpr::Fixed { value: 1 }, }, + ImperativeFamilyAst::Recruit => { + unreachable!("Recruit lowering constructs a sub-ability chain") + } ImperativeFamilyAst::ForceBlock { attacker, duration } => Effect::ForceBlock { target: TargetFilter::Any, attacker, @@ -21979,4 +22045,42 @@ mod tests { def.multi_target ); } + + #[test] + fn recruit_lowers_to_direct_discard_contingent_chain() { + let ast = parse_imperative_family_ast("recruit", "recruit", &mut ParseContext::default()) + .expect("Recruit should parse as its standalone keyword action"); + let draw = lower_imperative_family_ast(ast); + assert!(matches!(draw.effect, Effect::Draw { .. })); + let discard = draw + .sub_ability + .expect("Recruit draw must chain to discard"); + assert!(matches!(&*discard.effect, Effect::Discard { .. })); + let token = discard + .sub_ability + .expect("Recruit discard must have the contingent token as its direct child"); + assert!(matches!( + token.condition.as_ref(), + Some(AbilityCondition::DiscardedCardMatchesFilter { + filter: TargetFilter::Not { .. } + }) + )); + assert!(matches!( + &*token.effect, + Effect::Token { + power: PtValue::Fixed(1), + toughness: PtValue::Fixed(1), + .. + } + )); + } + + #[test] + fn recruiter_is_not_recruit() { + assert!( + parse_imperative_family_ast("recruiter", "recruiter", &mut ParseContext::default()) + .is_none(), + "the anchored Recruit grammar must not accept a longer word" + ); + } } diff --git a/crates/engine/src/parser/oracle_ir/ast.rs b/crates/engine/src/parser/oracle_ir/ast.rs index c58de09d2b..b9e492e710 100644 --- a/crates/engine/src/parser/oracle_ir/ast.rs +++ b/crates/engine/src/parser/oracle_ir/ast.rs @@ -609,6 +609,12 @@ pub(crate) enum ImperativeFamilyAst { Explore, /// CR 702.162a: Connive. Connive, + /// CR 701.70a + CR 608.2c: Recruit — draw, discard, then create the + /// contingent Soldier token when the card discarded by this instruction was + /// nonland. This remains a parser IR node because lowering must build the + /// three-step, direct-child chain rather than introduce a card-specific + /// runtime effect. + Recruit, /// CR 509.1c: Block this turn/combat if able. ForceBlock { attacker: Option, diff --git a/crates/engine/src/parser/oracle_keyword.rs b/crates/engine/src/parser/oracle_keyword.rs index e6bd07bf32..e6082c3e38 100644 --- a/crates/engine/src/parser/oracle_keyword.rs +++ b/crates/engine/src/parser/oracle_keyword.rs @@ -2366,6 +2366,7 @@ pub fn keyword_display_name(keyword: &Keyword) -> String { Keyword::Exploit => "exploit".to_string(), Keyword::Explore => "explore".to_string(), Keyword::Ascend => "ascend".to_string(), + Keyword::Storied => "storied".to_string(), Keyword::StartYourEngines => "start your engines!".to_string(), Keyword::Soulbond => "soulbond".to_string(), Keyword::Banding => "banding".to_string(), diff --git a/crates/engine/src/parser/oracle_nom/condition.rs b/crates/engine/src/parser/oracle_nom/condition.rs index ae56a69b3e..fcd46ebc9a 100644 --- a/crates/engine/src/parser/oracle_nom/condition.rs +++ b/crates/engine/src/parser/oracle_nom/condition.rs @@ -1248,6 +1248,11 @@ fn parse_player_state_conditions(input: &str) -> OracleResult<'_, StaticConditio StaticCondition::HasCityBlessing, tag("you have the city's blessing"), ), + // CR 702.195a-b: Storied grants the enduring story player designation. + value( + StaticCondition::HasEnduringStory, + tag("you have an enduring story"), + ), // CR 702.178a / CR 702.179f: Speed conditions. value( StaticCondition::HasMaxSpeed, @@ -13960,6 +13965,13 @@ mod tests { assert_eq!(c, StaticCondition::HasCityBlessing); } + #[test] + fn test_enduring_story() { + let (rest, condition) = parse_inner_condition("you have an enduring story").unwrap(); + assert_eq!(rest, ""); + assert_eq!(condition, StaticCondition::HasEnduringStory); + } + #[test] fn test_was_starting_player() { // CR 103.1: affirmative form. diff --git a/crates/engine/src/parser/oracle_trigger.rs b/crates/engine/src/parser/oracle_trigger.rs index 664a87ec12..18606e2aca 100644 --- a/crates/engine/src/parser/oracle_trigger.rs +++ b/crates/engine/src/parser/oracle_trigger.rs @@ -539,6 +539,8 @@ fn rewrite_cost_x_in_condition(cond: &mut crate::types::ability::AbilityConditio | AbilityCondition::IsMonarch | AbilityCondition::IsInitiative | AbilityCondition::HasCityBlessing + | AbilityCondition::HasEnduringStory + | AbilityCondition::DiscardedCardMatchesFilter { .. } | AbilityCondition::IsRingBearer | AbilityCondition::CompletedDungeon { .. } | AbilityCondition::TargetHasKeywordInstead { .. } @@ -4523,6 +4525,8 @@ pub(crate) fn static_condition_to_trigger_condition( StaticCondition::NoMonarch => Some(TriggerCondition::NoMonarch), // CR 702.131a: City's Blessing bridges directly. StaticCondition::HasCityBlessing => Some(TriggerCondition::HasCityBlessing), + // CR 702.195b: Enduring story bridges as a player designation. + StaticCondition::HasEnduringStory => Some(TriggerCondition::HasEnduringStory), // CR 110.5b: Source tapped state bridges for trigger conditions like // "At the beginning of your upkeep, if this land is tapped, ..." StaticCondition::SourceIsTapped => Some(TriggerCondition::SourceIsTapped), diff --git a/crates/engine/src/parser/oracle_trigger_tests.rs b/crates/engine/src/parser/oracle_trigger_tests.rs index 7c6e1b34de..63db1d7779 100644 --- a/crates/engine/src/parser/oracle_trigger_tests.rs +++ b/crates/engine/src/parser/oracle_trigger_tests.rs @@ -19939,6 +19939,14 @@ fn bridge_city_blessing() { ); } +#[test] +fn bridge_enduring_story() { + assert_eq!( + static_condition_to_trigger_condition(&StaticCondition::HasEnduringStory), + Some(TriggerCondition::HasEnduringStory), + ); +} + #[test] fn bridge_source_is_tapped() { assert_eq!( diff --git a/crates/engine/src/types/ability.rs b/crates/engine/src/types/ability.rs index 8d075a103f..4007783e56 100644 --- a/crates/engine/src/types/ability.rs +++ b/crates/engine/src/types/ability.rs @@ -7731,6 +7731,8 @@ pub enum StaticCondition { NoMonarch, /// CR 702.131a: True when the controller has the city's blessing (Ascend). HasCityBlessing, + /// CR 702.195b: True when the controller has the enduring story designation. + HasEnduringStory, /// CR 309.7: True when the controller has completed at least one dungeon. /// Used by "as long as you've completed a dungeon" statics (Nadaar, etc.). CompletedADungeon, @@ -8191,6 +8193,8 @@ pub enum ParsedCondition { }, /// CR 702.131a: True when the activating player has the city's blessing. HasCityBlessing, + /// CR 702.195b: True when the activating player has the enduring story designation. + HasEnduringStory, /// CR 102.1: "The active player is the player whose turn it is." True when /// the scoped player is the active player — gates a casting/restriction /// predicate on "if it's your turn". For "if it's not your turn" the parser @@ -18994,6 +18998,13 @@ pub enum AbilityCondition { /// CR 702.131c: "if you have the city's blessing" is true when the ability /// controller has the city's blessing designation. HasCityBlessing, + /// CR 702.195b: True when the ability controller has the enduring story designation. + HasEnduringStory, + /// CR 701.9a + CR 608.2c: True when the card discarded by the directly + /// preceding discard instruction matches `filter`. The resolver reads the + /// discard operation's captured hand-time result, never current-zone state + /// or a global last-discarded side channel. + DiscardedCardMatchesFilter { filter: TargetFilter }, /// CR 701.54a: True when the ability's source permanent is its controller's /// Ring-bearer. For "unless ~ is your Ring-bearer", wrap with `Not`. IsRingBearer, @@ -19283,6 +19294,8 @@ impl AbilityCondition { | AbilityCondition::IsMonarch | AbilityCondition::IsInitiative | AbilityCondition::HasCityBlessing + | AbilityCondition::HasEnduringStory + | AbilityCondition::DiscardedCardMatchesFilter { .. } | AbilityCondition::IsRingBearer | AbilityCondition::HasObjectTarget | AbilityCondition::IsYourTurn @@ -19486,10 +19499,26 @@ pub enum KickerVariant { Second, } +/// The captured outcome of one discard instruction, retained only for its +/// direct sub-ability. `lki` is taken while the card is still in hand, so a +/// replacement redirect or the new object in its destination zone cannot +/// change the contingent condition's answer (CR 400.7). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DiscardedCardResult { + pub object_id: ObjectId, + pub lki: crate::types::game_state::LKISnapshot, + pub final_zone: Zone, +} + /// Casting-time facts that flow with a spell from casting through resolution. /// Conditions in the sub_ability chain are evaluated against this context. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct SpellContext { + /// CR 701.9a + CR 608.2c: The result of the immediately preceding discard + /// instruction. `apply_parent_chain_context` copies this only to that + /// discard's direct child and clears it on every other hand-off. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub direct_discard_result: Option, /// CR 601.2c + CR 115.1: For a target slot announced by "an opponent's /// choice", the opponent the spell's controller chose to make that choice. /// In a multiplayer game the controller picks which opponent announces; @@ -19953,6 +19982,8 @@ pub enum TriggerCondition { }, /// CR 702.131a: "if you have the city's blessing" — true when the controller has Ascend. HasCityBlessing, + /// CR 702.195b: "if you have an enduring story" checks the player designation. + HasEnduringStory, /// CR 309.7: True when the controller has completed a dungeon. /// `specific: None` matches "have you completed any dungeon"; `specific: Some(d)` /// matches "have you completed `d`". Negation ("haven't completed Tomb of @@ -23992,6 +24023,30 @@ impl ResolvedAbility { } } + /// CR 701.9a + CR 608.2c: Stamp one completed discard result onto exactly + /// the deferred direct child. Descendants and alternate branches are + /// explicitly cleared so this one contingent fact cannot leak past its + /// immediate consumer. + pub fn set_direct_discard_result_for_immediate_node(&mut self, result: DiscardedCardResult) { + self.context.direct_discard_result = Some(result); + if let Some(sub) = self.sub_ability.as_mut() { + sub.clear_direct_discard_result_recursive(); + } + if let Some(else_branch) = self.else_ability.as_mut() { + else_branch.clear_direct_discard_result_recursive(); + } + } + + fn clear_direct_discard_result_recursive(&mut self) { + self.context.direct_discard_result = None; + if let Some(sub) = self.sub_ability.as_mut() { + sub.clear_direct_discard_result_recursive(); + } + if let Some(else_branch) = self.else_ability.as_mut() { + else_branch.clear_direct_discard_result_recursive(); + } + } + /// CR 701.47c + CR 608.2c: Stamp the Army chosen by an amass instruction /// across every continuation branch in this resolution. pub fn set_amassed_army_object_recursive(&mut self, snapshot: CostPaidObjectSnapshot) { diff --git a/crates/engine/src/types/events.rs b/crates/engine/src/types/events.rs index 411b485f57..a059c30e07 100644 --- a/crates/engine/src/types/events.rs +++ b/crates/engine/src/types/events.rs @@ -1344,6 +1344,10 @@ pub enum GameEvent { CityBlessingGained { player_id: PlayerId, }, + /// A player gained an enduring story. + EnduringStoryGained { + player_id: PlayerId, + }, /// CR 706: A die was rolled. `result` is `None` when the roll has no numeric /// face value — the symbolic planar die (CR 901.9d / CR 706.7): the /// `RolledDie` trigger still fires, but numeric-result consumers ignore it. diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index e384282403..5caa9655fa 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -9647,6 +9647,10 @@ pub enum WaitingFor { /// the player may discard 1 card matching this filter instead of `count`. #[serde(default, skip_serializing_if = "Option::is_none")] unless_filter: Option, + /// CR 701.9a + CR 614.1: Optional operation-owned provenance for a + /// resolving discard (not discard costs or Ward). + #[serde(default, skip_serializing_if = "Option::is_none")] + discard_frame: Option, }, /// CR 608.2d: Player chooses object(s) from a zone during effect resolution. /// Generalizes the DiscardChoice pattern to sacrifice-from-battlefield and hand-to-battlefield. @@ -14488,7 +14492,7 @@ declare_game_state! { /// time; an empty stack is omitted from raw live-state snapshots until the /// first migrated family parks work. #[serde(default, skip_serializing_if = "ResolutionStack::is_empty")] - pub resolution_stack: ResolutionStack, + pub resolution_stack: Box, /// Borrowed execution-local view of the active continuation's captured /// Aura host. The authoritative value remains inside @@ -14807,6 +14811,12 @@ declare_game_state! { #[serde(serialize_with = "crate::types::deterministic_serde::hash_set")] pub city_blessing: HashSet, + /// CR 702.195a-b: Players with an enduring story designation. Once gained, + /// it persists for the rest of the game. + #[serde(default, skip_serializing_if = "HashSet::is_empty")] + #[serde(serialize_with = "crate::types::deterministic_serde::hash_set")] + pub enduring_story: Box>, + /// CR 702.50a-b: Active Epic effects — one per resolved Epic spell. Each /// entry is a rest-of-game record: its controller can't cast spells /// (CR 702.50b, derived via `epic::is_epic_locked`) and, at the beginning of @@ -17902,7 +17912,7 @@ impl GameState { &mut self, command: &ResolvedFrameTransitionCommand, ) -> Result<(), ResolvedFrameTransitionReplayInvariantError> { - let mut resolution_stack = self.resolution_stack.clone(); + let mut resolution_stack = (*self.resolution_stack).clone(); match &command.transition { ResolvedFrameTransition::Push { frame } => resolution_stack.push_inner(frame.clone()), ResolvedFrameTransition::InsertParentOfActive { frame } => { @@ -17916,7 +17926,7 @@ impl GameState { } } resolution_stack.validate(&self.waiting_for)?; - self.resolution_stack = resolution_stack; + *self.resolution_stack = resolution_stack; Ok(()) } @@ -18823,7 +18833,7 @@ impl GameState { modal_modes_chosen_this_game: HashSet::new(), revealed_cards: HashSet::new(), public_revealed_cards: HashSet::new(), - resolution_stack: ResolutionStack::default(), + resolution_stack: Box::default(), resolving_continuation_attach_host: None, merged_card_component_route: None, resolution_coin_flip: None, @@ -18864,6 +18874,7 @@ impl GameState { exiled_from_hand_this_resolution: 0, monarch: None, city_blessing: HashSet::new(), + enduring_story: Box::default(), epic_effects: Vec::new(), restrictions: Vec::new(), pending_damage_replacements: Vec::new(), @@ -20557,6 +20568,7 @@ fn _gamestate_partition_is_total(s: &GameState) { exiled_from_hand_this_resolution: _, monarch: _, city_blessing: _, + enduring_story: _, epic_effects: _, restrictions: _, pending_damage_replacements: _, @@ -20856,6 +20868,7 @@ impl PartialEq for GameState { && self.lki_copiable_values == other.lki_copiable_values && self.lki_by_incarnation == other.lki_by_incarnation && self.city_blessing == other.city_blessing + && self.enduring_story == other.enduring_story && self.planar_deck == other.planar_deck && self.planar_controller == other.planar_controller && self.planar_die_actions_this_turn == other.planar_die_actions_this_turn @@ -22314,7 +22327,7 @@ mod tests { .active_ability_continuation() .expect("fixture parks a trigger continuation") .clone(); - state.resolution_stack = ResolutionStack::default(); + state.resolution_stack = Box::default(); let mut v1 = serde_json::to_value(state).expect("v1 fixture serializes"); v1["resolution_state_version"] = serde_json::Value::from(1); v1["pending_continuation"] = @@ -22483,7 +22496,7 @@ mod tests { .active_ability_continuation() .expect("fixture parks the trigger continuation") .clone(); - state.resolution_stack = ResolutionStack::default(); + state.resolution_stack = Box::default(); let mut wire = serde_json::to_value(state).expect("v1 fixture serializes"); wire["resolution_state_version"] = serde_json::Value::from(1); wire["pending_continuation"] = @@ -25297,6 +25310,7 @@ mod tests { effect_kind: crate::types::ability::EffectKind::Discard, up_to: false, unless_filter: None, + discard_frame: None, })); variants.push(Box::new(WaitingFor::EffectZoneChoice { player: PlayerId(0), diff --git a/crates/engine/src/types/identifiers.rs b/crates/engine/src/types/identifiers.rs index 754f098907..0367dc556e 100644 --- a/crates/engine/src/types/identifiers.rs +++ b/crates/engine/src/types/identifiers.rs @@ -35,6 +35,13 @@ pub(crate) const TRIGGERING_SPELL_PLACEHOLDER: ObjectId = ObjectId(u64::MAX); #[serde(transparent)] pub struct LogicalZoneChangeGroupId(pub u64); +/// Monotonic identity for one operation-owned discard result frame. This is +/// distinct from an object id: one discard instruction may be replaced or +/// paused, while the frame remains the sole provenance authority. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct DiscardFrameId(pub u64); + /// Unique identifier for a set of objects tracked across delayed trigger boundaries. /// CR 603.7: Delayed triggers reference the specific objects from the originating effect. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] diff --git a/crates/engine/src/types/keywords.rs b/crates/engine/src/types/keywords.rs index a5f9bca822..9ebca33da1 100644 --- a/crates/engine/src/types/keywords.rs +++ b/crates/engine/src/types/keywords.rs @@ -173,6 +173,7 @@ pub enum KeywordKind { Exploit, Explore, Ascend, + Storied, StartYourEngines, Dredge, Modular, @@ -628,6 +629,7 @@ pub enum Keyword { Exploit, Explore, Ascend, + Storied, /// CR 702.179: Grants the player a speed value via SBA and enables the inherent speed trigger. StartYourEngines, Dredge(u32), @@ -1232,6 +1234,7 @@ impl Keyword { | Keyword::Evolve | Keyword::Extort | Keyword::Ascend + | Keyword::Storied | Keyword::StartYourEngines | Keyword::Modular(_) | Keyword::Renown(_) @@ -1440,6 +1443,7 @@ impl Keyword { Keyword::Exploit => KeywordKind::Exploit, Keyword::Explore => KeywordKind::Explore, Keyword::Ascend => KeywordKind::Ascend, + Keyword::Storied => KeywordKind::Storied, Keyword::StartYourEngines => KeywordKind::StartYourEngines, Keyword::Dredge(_) => KeywordKind::Dredge, Keyword::Modular(_) => KeywordKind::Modular, @@ -2671,6 +2675,7 @@ impl FromStr for Keyword { "exploit" => Ok(Keyword::Exploit), "explore" => Ok(Keyword::Explore), "ascend" => Ok(Keyword::Ascend), + "storied" => Ok(Keyword::Storied), "startyourengines" => Ok(Keyword::StartYourEngines), "startyourengines!" => Ok(Keyword::StartYourEngines), "soulbond" => Ok(Keyword::Soulbond), @@ -3045,6 +3050,7 @@ fn keyword_from_tagged(variant: &str, data: &serde_json::Value) -> Result Ok(Keyword::Exploit), "Explore" => Ok(Keyword::Explore), "Ascend" => Ok(Keyword::Ascend), + "Storied" => Ok(Keyword::Storied), "StartYourEngines" => Ok(Keyword::StartYourEngines), "Soulbond" => Ok(Keyword::Soulbond), "Banding" => Ok(Keyword::Banding), @@ -4705,6 +4711,7 @@ mod tests { "Exploit", "Explore", "Ascend", + "Storied", "Soulbond", "Partner", "Banding", diff --git a/crates/engine/src/types/proposed_event.rs b/crates/engine/src/types/proposed_event.rs index 06bb9edd34..57ed504e08 100644 --- a/crates/engine/src/types/proposed_event.rs +++ b/crates/engine/src/types/proposed_event.rs @@ -418,6 +418,11 @@ pub enum ProposedEvent { /// delivered. #[serde(default, skip_serializing_if = "Option::is_none")] enter_as_copy: Option>, + /// CR 701.9a + CR 614.1: Preserves an operation-owned discard frame + /// through the inner hand-to-destination move and any replacement + /// choices. Unrelated zone changes omit it from the wire. + #[serde(default, skip_serializing_if = "Option::is_none")] + discard_frame: Option, #[serde(serialize_with = "crate::types::deterministic_serde::hash_set")] applied: HashSet, }, @@ -601,6 +606,11 @@ pub enum ProposedEvent { /// actions (cleanup hand-size discard). #[serde(default)] caused_by_effect: bool, + /// CR 701.9a + CR 614.1: Operation-owned provenance for an in-flight + /// discard. `None` preserves ordinary discard/cost behavior; Recruit + /// installs an id before the event enters replacement processing. + #[serde(default, skip_serializing_if = "Option::is_none")] + discard_frame: Option, #[serde(serialize_with = "crate::types::deterministic_serde::hash_set")] applied: HashSet, }, @@ -754,6 +764,7 @@ impl ProposedEvent { enter_transformed: false, face_down_profile: None, enter_as_copy: None, + discard_frame: None, applied: HashSet::new(), } } diff --git a/crates/engine/src/types/resolution.rs b/crates/engine/src/types/resolution.rs index cd9216dabd..715c53fe62 100644 --- a/crates/engine/src/types/resolution.rs +++ b/crates/engine/src/types/resolution.rs @@ -9,7 +9,7 @@ use std::collections::HashSet; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde_json::{Map, Value}; -use crate::types::ability::{AbilityDefinition, ResolvedAbility, TargetRef}; +use crate::types::ability::{AbilityDefinition, DiscardedCardResult, ResolvedAbility, TargetRef}; use crate::types::events::GameEvent; use crate::types::game_state::{ DrainStatus, DrawSequenceStack, GameState, GameStateDecode, GameStateDecodeMode, @@ -21,7 +21,7 @@ use crate::types::game_state::{ PendingVoteBallotIteration, PostReplacementDrain, PostReplacementDrainStack, ResidentDrainPolicy, ResolvingTriggerContext, WaitingFor, }; -use crate::types::identifiers::ObjectId; +use crate::types::identifiers::{DiscardFrameId, ObjectId}; use crate::types::player::PlayerId; /// The complete shipped draw authority carried by one `MultiDraw` frame. @@ -41,6 +41,18 @@ pub struct MultiDrawFrame { pub connive_reentry: Option, } +/// CR 701.9a + CR 614.1: One discard operation's serializable authority. +/// Replacement choices may suspend the operation, but only terminal delivery +/// may append the captured hand-time result. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DiscardFrame { + pub id: DiscardFrameId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_id: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub results: Vec, +} + /// CR 603.12a + CR 608.2c: A repeated optional-cost process parked at one /// payment decision. The count belongs to this one process and remains in its /// frame through the reflexive modal prompt after the last payment driver has @@ -189,6 +201,7 @@ pub enum ResolutionFrame { CoinFlip(PendingCoinFlip), Proliferate(PendingProliferateActions), MultiDraw(MultiDrawFrame), + Discard(Box), ConniveReentry(PendingConniveReentry), LifeTotalAssignment(PendingLifeTotalAssignment), SpellResolution(PendingSpellResolution), @@ -219,6 +232,7 @@ pub enum FrameKind { CoinFlip, Proliferate, MultiDraw, + Discard, ConniveReentry, LifeTotalAssignment, SpellResolution, @@ -248,6 +262,7 @@ impl ResolutionFrame { Self::CoinFlip(_) => FrameKind::CoinFlip, Self::Proliferate(_) => FrameKind::Proliferate, Self::MultiDraw(_) => FrameKind::MultiDraw, + Self::Discard(_) => FrameKind::Discard, Self::ConniveReentry(_) => FrameKind::ConniveReentry, Self::LifeTotalAssignment(_) => FrameKind::LifeTotalAssignment, Self::SpellResolution(_) => FrameKind::SpellResolution, @@ -283,6 +298,7 @@ impl ResolutionFrame { | Self::Proliferate(_) | Self::MutateMerge(_) | Self::MultiDraw(_) + | Self::Discard(_) | Self::ConniveReentry(_) | Self::LifeTotalAssignment(_) | Self::SpellResolution(_) @@ -321,6 +337,7 @@ impl ResolutionFrame { | Self::PerPlayerZoneChoice(_) | Self::PerCategoryZoneChoice(_) | Self::MultiDraw(_) + | Self::Discard(_) | Self::ConniveReentry(_) | Self::LifeTotalAssignment(_) | Self::SpellResolution(_) @@ -415,6 +432,10 @@ pub struct ResolutionStack { /// frame, so a stale captured ID cannot alias a later instruction. #[serde(default)] next_draw_sequence_frame_id: u64, + /// Monotonic allocator for operation-owned discard frames. It never + /// rewinds, so a stale replacement event cannot bind to a later discard. + #[serde(default)] + next_discard_frame_id: u64, } impl ResolutionStack { @@ -445,10 +466,34 @@ impl ResolutionStack { self.next_draw_sequence_frame_id = next_frame_id; } + pub(crate) fn next_discard_frame_id(&self) -> u64 { + self.next_discard_frame_id + } + + pub(crate) fn restore_next_discard_frame_id(&mut self, next_frame_id: u64) { + self.next_discard_frame_id = next_frame_id; + } + + /// Starts one discard operation and returns its unique provenance id. + pub fn begin_discard(&mut self, source_id: Option) -> DiscardFrameId { + let id = DiscardFrameId(self.next_discard_frame_id); + self.next_discard_frame_id = self.next_discard_frame_id.saturating_add(1); + self.push_discard(DiscardFrame { + id, + source_id, + results: Vec::new(), + }); + id + } + pub(crate) fn observe_draw_sequence_frame_id(&mut self, next_frame_id: u64) { self.next_draw_sequence_frame_id = self.next_draw_sequence_frame_id.max(next_frame_id); } + fn observe_discard_frame_id(&mut self, id: DiscardFrameId) { + self.next_discard_frame_id = self.next_discard_frame_id.max(id.0.saturating_add(1)); + } + /// Restores a v2 payload written before the outer allocator was serialized. /// The active frame's allocator is the lower bound, never a reset value. fn recover_draw_sequence_allocator(&mut self) { @@ -465,6 +510,22 @@ impl ResolutionStack { } } + /// Restores the monotonic discard allocator from persisted in-flight + /// operations when an older wire payload omits its outer allocator. + fn recover_discard_allocator(&mut self) { + let next_frame_id = self + .frames + .iter() + .filter_map(|frame| match frame { + ResolutionFrame::Discard(frame) => Some(frame.id.0.saturating_add(1)), + _ => None, + }) + .max(); + if let Some(next_frame_id) = next_frame_id { + self.next_discard_frame_id = self.next_discard_frame_id.max(next_frame_id); + } + } + /// Compares runtime frames with the `GameState` equality contract. /// /// A Devour-only ChangeZone frame preserves a live CR 614.12a/614.13a @@ -529,6 +590,69 @@ impl ResolutionStack { } } + /// Returns the active continuation only when the exact discard operation + /// is its immediate parent. This fixed adjacency is the authority that + /// permits a Recruit result to cross a replacement pause; arbitrary stack + /// searches would risk binding a sibling discard to the wrong child. + pub fn active_ability_continuation_with_discard_parent_mut( + &mut self, + discard_id: DiscardFrameId, + ) -> Option<&mut AbilityContinuationFrame> { + let continuation_index = self.frames.len().checked_sub(1)?; + let discard_index = continuation_index.checked_sub(1)?; + match ( + self.frames.get(discard_index), + self.frames.get(continuation_index), + ) { + ( + Some(ResolutionFrame::Discard(discard)), + Some(ResolutionFrame::AbilityContinuation(_)), + ) if discard.id == discard_id => {} + _ => return None, + } + match self.frames.get_mut(continuation_index) { + Some(ResolutionFrame::AbilityContinuation(continuation)) => Some(continuation), + Some(_) | None => { + unreachable!("checked direct continuation must retain its frame kind") + } + } + } + + /// Identifies the exact discard parent of the active continuation without + /// exposing any non-adjacent frame. + pub fn active_ability_continuation_discard_parent_id(&self) -> Option { + let continuation_index = self.frames.len().checked_sub(1)?; + let discard_index = continuation_index.checked_sub(1)?; + match ( + self.frames.get(discard_index), + self.frames.get(continuation_index), + ) { + ( + Some(ResolutionFrame::Discard(discard)), + Some(ResolutionFrame::AbilityContinuation(_)), + ) => Some(discard.id), + _ => None, + } + } + + pub fn active_ability_continuation_discard_parent_result( + &self, + discard_id: DiscardFrameId, + ) -> Option { + let continuation_index = self.frames.len().checked_sub(1)?; + let discard_index = continuation_index.checked_sub(1)?; + match ( + self.frames.get(discard_index), + self.frames.get(continuation_index), + ) { + ( + Some(ResolutionFrame::Discard(frame)), + Some(ResolutionFrame::AbilityContinuation(_)), + ) if frame.id == discard_id => frame.results.first().cloned(), + _ => None, + } + } + /// Consume the active ability-continuation frame. pub fn take_active_ability_continuation( &mut self, @@ -1834,6 +1958,47 @@ impl ResolutionStack { self.push_inner(ResolutionFrame::ConniveReentry(pending)); } + /// Returns the active discard operation only when it owns the stack top. + /// Callers cannot recover a buried operation through a nested replacement + /// choice, preserving LIFO provenance. + pub fn active_discard(&self) -> Option<&DiscardFrame> { + match self.last() { + Some(ResolutionFrame::Discard(frame)) => Some(frame), + Some(_) | None => None, + } + } + + /// Mutably accesses only the active discard operation's terminal results. + pub fn active_discard_mut(&mut self) -> Option<&mut DiscardFrame> { + match self.frames.last_mut() { + Some(ResolutionFrame::Discard(frame)) => Some(frame), + Some(_) | None => None, + } + } + + /// Consumes exactly the active discard frame. + pub fn take_active_discard(&mut self) -> Result, ResolutionStackError> { + match self.last() { + None => Ok(None), + Some(ResolutionFrame::Discard(_)) => { + let ResolutionFrame::Discard(frame) = self.pop_expected(FrameKind::Discard)? else { + unreachable!("checked discard frame kind must match") + }; + Ok(Some(*frame)) + } + Some(frame) => Err(ResolutionStackError::UnexpectedTop { + expected: FrameKind::Discard, + actual: frame.kind(), + }), + } + } + + /// Parks a discard operation before it enters replacement processing. + pub fn push_discard(&mut self, frame: DiscardFrame) { + self.observe_discard_frame_id(frame.id); + self.push_inner(ResolutionFrame::Discard(Box::new(frame))); + } + /// Returns a life-total assignment tail only when it owns the active stack /// top. pub fn active_life_total_assignment(&self) -> Option<&PendingLifeTotalAssignment> { @@ -2328,6 +2493,7 @@ impl ResolutionStack { }); } let has_multi_draw = multi_draw_count == 1; + let mut discard_ids = HashSet::new(); let mut direct_choice_count = 0; let mut buried_direct_choice = None; for (index, frame) in self.frames.iter().enumerate() { @@ -2352,6 +2518,22 @@ impl ResolutionStack { }); } } + if let ResolutionFrame::Discard(discard) = frame { + if !discard_ids.insert(discard.id) { + return Err(ResolutionStackError::InvalidPayload { + frame: FrameKind::Discard, + message: "duplicate discard frame id".to_string(), + }); + } + if discard.id.0 >= self.next_discard_frame_id { + return Err(ResolutionStackError::InvalidPayload { + frame: FrameKind::Discard, + message: + "the resolution-stack discard allocator is behind its active frame" + .to_string(), + }); + } + } if has_multi_draw && matches!( frame, @@ -2680,6 +2862,7 @@ impl ResolutionStateWire { let mut frames: ResolutionStack = serde_json::from_value(frames_value.clone()) .map_err(|error| error.to_string())?; frames.recover_draw_sequence_allocator(); + frames.recover_discard_allocator(); let mut state_value = value; let state_object = state_value.as_object_mut().expect("checked JSON object"); @@ -3349,6 +3532,7 @@ pub(crate) fn canonicalize_legacy_resolution_state( let mut frames = ResolutionStack::default(); frames .restore_next_draw_sequence_frame_id(state.resolution_stack.next_draw_sequence_frame_id()); + frames.restore_next_discard_frame_id(state.resolution_stack.next_discard_frame_id()); for frame in state.resolution_stack.iter() { if !frame.is_runtime_stack_resident() { @@ -3371,6 +3555,9 @@ fn project_frames_into_legacy_state( projected .resolution_stack .restore_next_draw_sequence_frame_id(frames.next_draw_sequence_frame_id()); + projected + .resolution_stack + .restore_next_discard_frame_id(frames.next_discard_frame_id()); for frame in frames.iter() { match frame { ResolutionFrame::AbilityContinuation(frame) => { @@ -3435,6 +3622,9 @@ fn project_frames_into_legacy_state( ResolutionFrame::MultiDraw(frame) => { projected.resolution_stack.push_multi_draw(frame.clone()) } + ResolutionFrame::Discard(frame) => { + projected.resolution_stack.push_discard((**frame).clone()) + } ResolutionFrame::ConniveReentry(pending) => projected .resolution_stack .push_connive_reentry(pending.clone()), @@ -3455,7 +3645,7 @@ fn project_frames_into_legacy_state( } fn clear_legacy_resolution_slots(state: &mut GameState) { - state.resolution_stack = ResolutionStack::default(); + *state.resolution_stack = Default::default(); } fn legacy_resolution_wire_field(object: &Map) -> Option<&str> { @@ -5226,6 +5416,48 @@ mod tests { ); } + #[test] + fn v2_reader_recovers_discard_allocator_and_rejects_duplicate_frame_ids() { + let mut state = GameState::new_two_player(140); + let captured = state.resolution_stack.begin_discard(None); + let v2 = serde_json::to_value(ResolutionStateWire::from_game_state(state)) + .expect("v2 active discard fixture serializes"); + + let mut stale_outer_allocator = v2.clone(); + stale_outer_allocator["resolution_frames"]["next_discard_frame_id"] = Value::from(0); + let mut restored = serde_json::from_value::(stale_outer_allocator) + .expect("stale discard allocator is repaired from the active frame") + .into_game_state(); + assert!( + restored.resolution_stack.next_discard_frame_id() > captured.0, + "a restored discard allocator must remain above every live frame id" + ); + let retired = restored + .resolution_stack + .take_active_discard() + .expect("restored discard frame must be active") + .expect("restored discard frame remains addressable"); + assert_eq!(retired.id, captured); + assert!( + restored.resolution_stack.begin_discard(None) > captured, + "the recovered allocator must not reuse an abandoned discard frame id" + ); + + let mut duplicate_frames = ResolutionStack::default(); + let _duplicate_id = duplicate_frames.begin_discard(None); + let duplicate = duplicate_frames + .active_discard() + .expect("new discard frame exists") + .clone(); + duplicate_frames.push_discard(duplicate); + let duplicate_wire = + v2_fixture_with_frames(GameState::new_two_player(141), duplicate_frames); + assert!( + serde_json::from_value::(duplicate_wire).is_err(), + "a wire payload with duplicate discard frame ids must be rejected" + ); + } + #[test] fn v1_remaining_resolution_frames_resume_via_shipped_authorities() { let mut draw_sequences = DrawSequenceStack::default(); diff --git a/crates/engine/tests/fixtures/cr733/authority_matrix.json.gz b/crates/engine/tests/fixtures/cr733/authority_matrix.json.gz index e55178196e..a89934db67 100644 Binary files a/crates/engine/tests/fixtures/cr733/authority_matrix.json.gz and b/crates/engine/tests/fixtures/cr733/authority_matrix.json.gz differ diff --git a/crates/engine/tests/integration/bards_company_recruit.rs b/crates/engine/tests/integration/bards_company_recruit.rs new file mode 100644 index 0000000000..f185f2e8b6 --- /dev/null +++ b/crates/engine/tests/integration/bards_company_recruit.rs @@ -0,0 +1,98 @@ +//! Bard's Company — the generated card-data trigger must run through the public +//! cast/resolve pipeline and inspect the actual discarded card's hand-time type. + +use engine::game::scenario::{GameRunner, GameScenario, P0}; +use engine::game::scenario_db::GameScenarioDbExt; +use engine::types::actions::GameAction; +use engine::types::card_type::CoreType; +use engine::types::game_state::WaitingFor; +use engine::types::identifiers::ObjectId; +use engine::types::mana::{ManaColor, ManaType, ManaUnit}; +use engine::types::phase::Phase; +use engine::types::zones::Zone; + +const BARDS_COMPANY: &str = "Bard's Company"; + +fn resolve_recruit(discard_land: bool, db: &engine::database::card_db::CardDatabase) -> GameRunner { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + scenario.with_mana_pool( + P0, + vec![ + ManaUnit::new(ManaType::Colorless, ObjectId(0), false, vec![]), + ManaUnit::new(ManaType::Colorless, ObjectId(0), false, vec![]), + ManaUnit::new(ManaType::White, ObjectId(0), false, vec![]), + ManaUnit::new(ManaType::Blue, ObjectId(0), false, vec![]), + ], + ); + let bards_company = scenario.add_real_card(P0, BARDS_COMPANY, Zone::Hand, db); + let discarded = if discard_land { + scenario.add_land_to_hand(P0, "Recruit land").id() + } else { + scenario.add_spell_to_hand(P0, "Recruit nonland", true).id() + }; + scenario.with_library_top(P0, &["Recruit draw"]); + let mut runner = scenario.build(); + engine::game::rehydrate_game_from_card_db(runner.state_mut(), db); + + runner.cast(bards_company).resolve(); + runner.resolve_top(); + assert!( + matches!(&runner.state().waiting_for, WaitingFor::DiscardChoice { cards, .. } if cards.contains(&discarded)), + "Bard's Company's ETB Recruit trigger must ask for a discard, got {:?}", + runner.state().waiting_for + ); + runner + .act(GameAction::SelectCards { + cards: vec![discarded], + }) + .expect("chosen Recruit discard resolves"); + runner +} + +fn human_soldiers(runner: &GameRunner) -> Vec<&engine::game::game_object::GameObject> { + runner + .state() + .objects + .values() + .filter(|object| { + object.is_token + && object.zone == Zone::Battlefield + && object.base_power == Some(1) + && object.base_toughness == Some(1) + && object.color == vec![ManaColor::White] + && object.card_types.core_types.contains(&CoreType::Creature) + && object.card_types.subtypes.contains(&"Human".to_string()) + && object.card_types.subtypes.contains(&"Soldier".to_string()) + }) + .collect() +} + +#[test] +fn bards_company_recruit_creates_a_human_soldier_only_for_nonland_discards() { + let Some(db) = crate::support::shared_card_db() else { + return; + }; + // The shared loader uses the committed, generated subset so this remains + // fast and visible in CI. Regenerate it from the real export after a + // serialized Recruit parser change. + if db.get_face_by_name(BARDS_COMPANY).is_none() { + eprintln!( + "skipping: Bard's Company is not in integration_cards.json — add it once the authoritative card export includes it" + ); + return; + } + + let nonland = resolve_recruit(false, db); + assert_eq!( + human_soldiers(&nonland).len(), + 1, + "discarding a nonland must create exactly one 1/1 white Human Soldier" + ); + + let land = resolve_recruit(true, db); + assert!( + human_soldiers(&land).is_empty(), + "discarding a land must not create Recruit's contingent token" + ); +} diff --git a/crates/engine/tests/integration/deterministic_game_state_serde.rs b/crates/engine/tests/integration/deterministic_game_state_serde.rs index cddf881f90..9e32218e39 100644 --- a/crates/engine/tests/integration/deterministic_game_state_serde.rs +++ b/crates/engine/tests/integration/deterministic_game_state_serde.rs @@ -216,6 +216,15 @@ fn expected_manifest() -> BTreeMap { Classification::Canonical(HASH_SET), ); } + add_spec( + &mut specs, + game_state, + "GameState", + None, + "enduring_story", + "Box", + Classification::Canonical(HASH_SET), + ); add_spec( &mut specs, game_state, @@ -834,7 +843,7 @@ fn unordered_map_key_types(ty: &Type) -> Vec { return Vec::new(); }; match segment.ident.to_string().as_str() { - "Option" | "Vec" | "Arc" => first_type_argument(segment) + "Option" | "Vec" | "Arc" | "Box" => first_type_argument(segment) .map(unordered_map_key_types) .unwrap_or_default(), "HashMap" => { @@ -859,7 +868,7 @@ fn hash_shape(ty: &Type) -> Option { let segment = path.path.segments.last()?; let name = segment.ident.to_string(); match name.as_str() { - "Option" | "Vec" | "Arc" => { + "Option" | "Vec" | "Arc" | "Box" => { hash_shape(first_type_argument(segment)?).map(|inner| format!("{name}<{inner}>")) } "HashSet" => Some( @@ -1192,6 +1201,7 @@ fn build_populated_state(reverse: bool) -> GameState { token_creator_order.reverse(); } state.players_who_created_token_this_turn = token_creator_order.into_iter().collect(); + state.enduring_story = Box::new(player_order.iter().copied().collect()); state.ring_level = player_order .into_iter() .map(|player| (player, player.0 + 1)) @@ -1331,6 +1341,7 @@ fn assert_representative_membership(state: &GameState) { assert!(state.players_who_created_token_this_turn.contains(&player)); } assert_eq!(state.ring_level.len(), 2); + assert_eq!(state.enduring_story.len(), 2); assert_eq!(state.attacked_defenders_this_turn.len(), 2); assert_eq!(state.steps_to_skip.len(), 2); assert_eq!(state.objects.len(), 2); diff --git a/crates/engine/tests/integration/integration_bending.rs b/crates/engine/tests/integration/integration_bending.rs index 009f1c8f2d..a05c194d13 100644 --- a/crates/engine/tests/integration/integration_bending.rs +++ b/crates/engine/tests/integration/integration_bending.rs @@ -1865,6 +1865,7 @@ fn earthbend_return_skips_shock_land_pay_life_prompt() { enter_transformed: false, face_down_profile: None, enter_as_copy: None, + discard_frame: None, applied: std::collections::HashSet::new(), }; @@ -1941,6 +1942,7 @@ fn plain_shock_land_etb_still_prompts_for_life_payment() { enter_transformed: false, face_down_profile: None, enter_as_copy: None, + discard_frame: None, applied: std::collections::HashSet::new(), }; diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index 28b0eabda8..80c1e5e2d8 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -40,6 +40,7 @@ mod backup_becomes_target_trigger; mod balance_equalization; mod baleful_mastery_regression; mod banding_combat; +mod bards_company_recruit; mod batched_trigger_subject_count; mod battle_of_wits; mod battlefield_entry_authority_census; diff --git a/crates/phase-ai/src/search.rs b/crates/phase-ai/src/search.rs index 7ead21b968..965515acdf 100644 --- a/crates/phase-ai/src/search.rs +++ b/crates/phase-ai/src/search.rs @@ -11871,6 +11871,7 @@ mod tests { effect_kind: EffectKind::DiscardCard, up_to: false, unless_filter: None, + discard_frame: None, } }); push("EffectZoneChoice", &|state| {