From ddb59c13659b9d164f3bce6466a13393df67594e Mon Sep 17 00:00:00 2001 From: matthewevans Date: Wed, 22 Jul 2026 17:37:48 -0700 Subject: [PATCH] cr733(p2): journal information-boundary commands --- crates/engine/src/game/effects/reveal_hand.rs | 22 +- .../engine/src/game/effects/reveal_until.rs | 65 ++- crates/engine/src/game/engine.rs | 51 ++- .../src/game/engine_resolution_choices.rs | 36 +- crates/engine/src/game/engine_tests.rs | 25 +- crates/engine/src/game/zones.rs | 18 +- crates/engine/src/types/game_state.rs | 385 +++++++++++++++++- crates/engine/src/types/resolved_commands.rs | 205 ++++++++++ .../integration/cr733_resolved_commands_p2.rs | 8 +- 9 files changed, 772 insertions(+), 43 deletions(-) diff --git a/crates/engine/src/game/effects/reveal_hand.rs b/crates/engine/src/game/effects/reveal_hand.rs index 8e06e480f1..a108fb2b49 100644 --- a/crates/engine/src/game/effects/reveal_hand.rs +++ b/crates/engine/src/game/effects/reveal_hand.rs @@ -8,6 +8,9 @@ use crate::types::ability::{ }; use crate::types::events::GameEvent; use crate::types::game_state::{GameState, WaitingFor}; +use crate::types::resolved_commands::{ + ResolvedInformationAudience, ResolvedInformationEdit, ResolvedInformationLifetime, +}; /// CR 701.20a / CR 701.20e: RevealHand — reveal or privately look at a target /// player's hand, then optionally let the caster choose a card. @@ -121,9 +124,22 @@ pub fn resolve( // CR 701.20b: Revealing a card doesn't cause it to leave the zone it's in. if is_reveal { - for &card_id in &hand { - state.revealed_cards.insert(card_id); - } + state + .resolve_and_apply_information( + &hand, + ResolvedInformationAudience::Controller(ability.controller), + ResolvedInformationLifetime::UntilActionBoundary, + ResolvedInformationEdit::Reveal, + ) + .expect("resolved hand-reveal occurrences must be live and distinct"); + state + .resolve_and_apply_information( + &hand, + ResolvedInformationAudience::Public, + ResolvedInformationLifetime::UntilZoneChange, + ResolvedInformationEdit::Reveal, + ) + .expect("published hand-reveal occurrences must be live and distinct"); // Emit event with card names let card_names: Vec = hand diff --git a/crates/engine/src/game/effects/reveal_until.rs b/crates/engine/src/game/effects/reveal_until.rs index 0a35c1f853..861c3742dd 100644 --- a/crates/engine/src/game/effects/reveal_until.rs +++ b/crates/engine/src/game/effects/reveal_until.rs @@ -10,6 +10,9 @@ use crate::types::events::GameEvent; use crate::types::game_state::{BatchCompletion, GameState, WaitingFor}; use crate::types::identifiers::ObjectId; use crate::types::player::PlayerId; +use crate::types::resolved_commands::{ + ResolvedInformationAudience, ResolvedInformationEdit, ResolvedInformationLifetime, +}; use crate::types::zones::{EtbTapState, Zone}; /// CR 701.20a: Reveal cards from the top of the controller's library one at a @@ -121,9 +124,6 @@ pub fn resolve( // nothing (CR 701.20a — the until-condition is already satisfied). if target_match_count > 0 { for &card_id in &library { - // Mark as revealed (CR 701.20b: card stays in library zone during reveal). - state.revealed_cards.insert(card_id); - if matches_target_filter(state, card_id, filter, &ctx) { hit_cards.push(card_id); if hit_cards.len() >= target_match_count { @@ -139,6 +139,31 @@ pub fn resolve( let mut all_revealed: Vec = revealed_misses.clone(); all_revealed.extend(&hit_cards); + state + .resolve_and_apply_information( + &all_revealed, + ResolvedInformationAudience::Controller(ability.controller), + ResolvedInformationLifetime::UntilActionBoundary, + ResolvedInformationEdit::Reveal, + ) + .expect("resolved reveal-until occurrences must be live and distinct"); + + // CR 701.20a + CR 400.7: Only reveal-only and paused optional dispositions + // retain these exact library occurrences after the resolver returns. Publish + // those occurrences here, before any later zone change can create a new one. + if matches!(matched_disposition, RevealUntilDisposition::RevealOnly) + || matches!((kept_optional_to, hit_cards.as_slice()), (Some(_), [_])) + { + state + .resolve_and_apply_information( + &all_revealed, + ResolvedInformationAudience::Public, + ResolvedInformationLifetime::UntilZoneChange, + ResolvedInformationEdit::Reveal, + ) + .expect("published reveal-until occurrences must be live and distinct"); + } + // Emit CardsRevealed for all revealed cards. let card_names: Vec = all_revealed .iter() @@ -367,10 +392,17 @@ pub fn resolve( } } - // Clear reveal markers — cards have moved zones. - for &card_id in &clear_markers { - state.revealed_cards.remove(&card_id); - } + // Zone delivery already clears the old occurrences through the shared + // information authority. This no-op-safe call covers a same-zone placement + // implementation that leaves a reveal lease behind. + state + .resolve_and_apply_information( + &clear_markers, + ResolvedInformationAudience::Controller(ability.controller), + ResolvedInformationLifetime::UntilActionBoundary, + ResolvedInformationEdit::Hide, + ) + .expect("reveal-until cleanup must reference live card occurrences"); events.push(GameEvent::EffectResolved { kind: EffectKind::RevealUntil, @@ -421,8 +453,6 @@ fn resolve_choose_any_number( // found (or the library runs out). `target_match_count == 0` reveals nothing. if target_match_count > 0 { for &card_id in library { - // CR 701.20b: the card stays in its library zone while revealed. - state.revealed_cards.insert(card_id); revealed.push(card_id); if matches_target_filter(state, card_id, filter, ctx) { matched.push(card_id); @@ -433,6 +463,23 @@ fn resolve_choose_any_number( } } + state + .resolve_and_apply_information( + &revealed, + ResolvedInformationAudience::Controller(ability.controller), + ResolvedInformationLifetime::UntilActionBoundary, + ResolvedInformationEdit::Reveal, + ) + .expect("resolved reveal-until occurrences must be live and distinct"); + state + .resolve_and_apply_information( + &revealed, + ResolvedInformationAudience::Public, + ResolvedInformationLifetime::UntilZoneChange, + ResolvedInformationEdit::Reveal, + ) + .expect("published reveal-until occurrences must be live and distinct"); + // CR 701.20a: emit a single CardsRevealed for the whole revealed pile. let card_names: Vec = revealed .iter() diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index c2a7d7cf94..cd6e10ddb8 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -1,5 +1,5 @@ use rand::Rng; -use std::collections::VecDeque; +use std::collections::{HashSet, VecDeque}; use thiserror::Error; use crate::types::ability::{EffectKind, KeywordAction, TargetRef}; @@ -21,6 +21,10 @@ use crate::types::phase::Phase; use crate::types::player::PlayerId; #[cfg(debug_assertions)] use crate::types::resolution::debug_assert_runtime_resolution_invariants; +use crate::types::resolved_commands::{ + ResolvedInformationAudience, ResolvedInformationEdit, ResolvedInformationLifetime, + ResolvedRulesCommand, +}; use crate::types::statics::StaticMode; use crate::types::zones::Zone; @@ -292,6 +296,7 @@ pub(super) fn apply_action_boundary_with_stack_limit( ) -> Result { mana_sources::preflight_tap_land_action(state, semantic_owner, &action)?; let boundary_snapshot = state.clone(); + let journal_start = state.resolved_rules_journal.entries().len(); interaction::ensure_interaction_authority(state); let previous_interaction_waiting = state.waiting_for.clone(); let previous_interaction_slots = state.active_interaction_slots.clone(); @@ -327,7 +332,7 @@ pub(super) fn apply_action_boundary_with_stack_limit( // pool that a spend during this action depleted, before public state is // finalized and the next affordability probe runs. No-op when none flagged. super::mana_payment::refill_infinite_mana(state); - remember_public_reveals(state, &result.events); + remember_public_reveals(state, &result.events, journal_start); // Targeted public-state dirty marking over the full accumulated event set // (the auto-pass loop appends events). `finalize_public_state` is the only // consumer of `public_state_dirty`, so marking once here over the complete @@ -2963,10 +2968,48 @@ fn handle_respond_to_shortcut( Ok(result) } -fn remember_public_reveals(state: &mut GameState, events: &[GameEvent]) { +fn remember_public_reveals(state: &mut GameState, events: &[GameEvent], journal_start: usize) { + // The journal is truncated at turn boundaries, so an action that + // auto-advances across a turn leaves `journal_start` past the current end. + // Clamp with `get(..)` so a truncated journal yields no this-action + // controller reveals rather than panicking on an out-of-bounds slice. + let controller_reveals = state + .resolved_rules_journal + .entries() + .get(journal_start..) + .unwrap_or(&[]) + .iter() + .filter_map(|entry| entry.command.as_ref()) + .filter_map(|command| match command { + ResolvedRulesCommand::Information(information) + if matches!( + information.audience, + ResolvedInformationAudience::Controller(_) + ) && matches!(information.edit, ResolvedInformationEdit::Reveal) => + { + Some(&information.occurrences) + } + _ => None, + }) + .flatten() + .map(|occurrence| occurrence.object_id) + .collect::>(); + for event in events { if let GameEvent::CardsRevealed { card_ids, .. } = event { - state.public_revealed_cards.extend(card_ids.iter().copied()); + let unpublished = card_ids + .iter() + .copied() + .filter(|card_id| !controller_reveals.contains(card_id)) + .collect::>(); + state + .resolve_and_apply_information( + &unpublished, + ResolvedInformationAudience::Public, + ResolvedInformationLifetime::UntilZoneChange, + ResolvedInformationEdit::Reveal, + ) + .expect("published reveal occurrences must be live and distinct"); } } } diff --git a/crates/engine/src/game/engine_resolution_choices.rs b/crates/engine/src/game/engine_resolution_choices.rs index 4251016156..a897ab3330 100644 --- a/crates/engine/src/game/engine_resolution_choices.rs +++ b/crates/engine/src/game/engine_resolution_choices.rs @@ -13,6 +13,9 @@ use crate::types::game_state::{ }; use crate::types::identifiers::{ObjectId, TrackedSetId}; use crate::types::mana::ManaCost; +use crate::types::resolved_commands::{ + ResolvedInformationAudience, ResolvedInformationEdit, ResolvedInformationLifetime, +}; use crate::types::zones::Zone; use super::effects; @@ -3310,9 +3313,14 @@ pub(super) fn handle_resolution_choice( // replacement's decline ability runs via `pending_continuation`, which the // effect's resolver populated with the decline branch before the prompt. if optional && chosen.is_empty() { - for &card_id in &cards { - state.revealed_cards.remove(&card_id); - } + state + .resolve_and_apply_information( + &cards, + ResolvedInformationAudience::Controller(player), + ResolvedInformationLifetime::UntilActionBoundary, + ResolvedInformationEdit::Hide, + ) + .expect("reveal-choice cleanup must reference live card occurrences"); state.private_look_ids.clear(); state.private_look_player = None; set_priority(state, player); @@ -3353,9 +3361,14 @@ pub(super) fn handle_resolution_choice( )); } - for &card_id in &cards { - state.revealed_cards.remove(&card_id); - } + state + .resolve_and_apply_information( + &cards, + ResolvedInformationAudience::Controller(player), + ResolvedInformationLifetime::UntilActionBoundary, + ResolvedInformationEdit::Hide, + ) + .expect("reveal-choice cleanup must reference live card occurrences"); state.private_look_ids.clear(); state.private_look_player = None; @@ -7223,9 +7236,14 @@ pub(crate) fn run_batch_completion( events, ); } - for card_id in &clear_markers { - state.revealed_cards.remove(card_id); - } + state + .resolve_and_apply_information( + &clear_markers, + ResolvedInformationAudience::Controller(player), + ResolvedInformationLifetime::UntilActionBoundary, + ResolvedInformationEdit::Hide, + ) + .expect("reveal-rest cleanup must reference live card occurrences"); if let Some(kept) = publish_tracked_set { effects::publish_fresh_tracked_set(state, kept.clone()); if let Some(frame) = state.active_ability_continuation_frame_mut() { diff --git a/crates/engine/src/game/engine_tests.rs b/crates/engine/src/game/engine_tests.rs index 3cb6acc482..5b04b68e60 100644 --- a/crates/engine/src/game/engine_tests.rs +++ b/crates/engine/src/game/engine_tests.rs @@ -72,16 +72,37 @@ fn no_op_stack_entry(id: u64, controller: PlayerId) -> StackEntry { #[test] fn cards_revealed_events_are_remembered_publicly() { let mut state = GameState::new_two_player(42); - let card_id = ObjectId(42); + let card_id = create_object( + &mut state, + CardId(42), + PlayerId(1), + "Known Card".to_string(), + Zone::Hand, + ); let events = vec![GameEvent::CardsRevealed { player: PlayerId(1), card_ids: vec![card_id], card_names: vec!["Known Card".to_string()], }]; + let pre_state = state.clone(); - remember_public_reveals(&mut state, &events); + remember_public_reveals(&mut state, &events, 0); assert!(state.public_revealed_cards.contains(&card_id)); + let command = state + .resolved_rules_journal + .entries() + .iter() + .find_map(|entry| match &entry.command { + Some(crate::types::resolved_commands::ResolvedRulesCommand::Information(command)) => { + Some(command.clone()) + } + _ => None, + }) + .expect("published reveal must record its exact information command"); + let mut replay = pre_state; + replay.apply_resolved_information(&command).unwrap(); + assert_eq!(replay.public_revealed_cards, state.public_revealed_cards); } /// CR 603.3d regression — reported turn-34 Commander freeze (All Will Be diff --git a/crates/engine/src/game/zones.rs b/crates/engine/src/game/zones.rs index 990a8a5ed9..41deec1a52 100644 --- a/crates/engine/src/game/zones.rs +++ b/crates/engine/src/game/zones.rs @@ -136,15 +136,15 @@ pub(crate) fn apply_zone_exit_cleanup( attachments: Vec, ) { // CR 400.7: An object that changes zones becomes a new object with no - // memory of its previous existence. Both the short-lived `revealed_cards` - // (cleared at action boundaries) and the persistent `public_revealed_cards` - // (reveal memory that survives action boundaries so e.g. a Duress-revealed - // card stays visible in the opponent's hand) are keyed by ObjectId. Since - // ObjectId here is storage identity and persists across the zone change, - // we must drop both flags so a card shuffled back into the library and - // re-drawn does not surface as "still revealed." - state.revealed_cards.remove(&object_id); - state.public_revealed_cards.remove(&object_id); + // memory of its previous existence. The information authority receives the + // pre-move occurrence so the future Zone command can apply this same clear + // without ever exposing the new incarnation through the old reveal lease. + let occurrence = state + .objects + .get(&object_id) + .map(ObjectIncarnationRef::from_object) + .expect("zone-exit cleanup must reference a live object"); + state.clear_revealed_information_on_zone_exit(occurrence); // CR 400.7 + CR 702.187b: The "discarded this turn" mark (Mayhem's gate) // belongs to the old object. Clear it on any zone change so a card that // leaves the graveyard and returns is not treated as still discarded; the diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs index 0a2789e1eb..00154dd7b0 100644 --- a/crates/engine/src/types/game_state.rs +++ b/crates/engine/src/types/game_state.rs @@ -50,10 +50,11 @@ use super::resolution::{ ResolutionFrame, ResolutionStack, ResolutionStackError, ResolutionStateWire, }; use super::resolved_commands::{ - ManaPaymentRecipient, ResolvedManaInsertCommand, ResolvedManaReplayInvariantError, - ResolvedManaSpendCommand, ResolvedPlayerEdit, ResolvedPlayerEditCommand, - ResolvedPlayerEditReplayInvariantError, ResolvedRngReplayInvariantError, ResolvedRulesJournal, - RulesExecutionNodeRef, + ManaPaymentRecipient, ResolvedInformationAudience, ResolvedInformationCommand, + ResolvedInformationEdit, ResolvedInformationLifetime, ResolvedInformationReplayInvariantError, + ResolvedManaInsertCommand, ResolvedManaReplayInvariantError, ResolvedManaSpendCommand, + ResolvedPlayerEdit, ResolvedPlayerEditCommand, ResolvedPlayerEditReplayInvariantError, + ResolvedRngReplayInvariantError, ResolvedRulesJournal, RulesExecutionNodeRef, }; use super::zones::EtbTapState; use super::zones::{ExileCostSourceZone, Zone}; @@ -15359,6 +15360,205 @@ impl GameState { Ok(()) } + /// CR 701.20a: Construct, apply, and journal one exact reveal or hide + /// transition after the ordinary path has selected the affected cards. + /// + /// Replays apply the stored occurrences directly; they never inspect a + /// library, hand, target filter, or event payload to determine what was + /// revealed. Already-correct occurrences are deliberately omitted so an + /// empty or duplicate reveal/hide never creates a journal entry. + pub fn resolve_and_apply_information( + &mut self, + object_ids: &[ObjectId], + audience: ResolvedInformationAudience, + lifetime: ResolvedInformationLifetime, + edit: ResolvedInformationEdit, + ) -> Result, ResolvedInformationReplayInvariantError> { + let mut seen = HashSet::new(); + let mut occurrences = Vec::with_capacity(object_ids.len()); + for object_id in object_ids { + if !seen.insert(*object_id) { + continue; + } + let Some(object) = self.objects.get(object_id) else { + continue; + }; + let occurrence = ObjectIncarnationRef::from_object(object); + let active = match audience { + ResolvedInformationAudience::Controller(_) => { + self.revealed_cards.contains(object_id) + } + ResolvedInformationAudience::Public => { + self.public_revealed_cards.contains(object_id) + } + }; + if matches!(edit, ResolvedInformationEdit::Reveal) != active { + occurrences.push(occurrence); + } + } + if occurrences.is_empty() { + return Ok(None); + } + + let command = ResolvedInformationCommand { + occurrences, + audience, + lifetime, + edit, + cause: self.current_or_begin_rules_execution_node(), + }; + self.apply_resolved_information(&command)?; + self.resolved_rules_journal + .record_information(command.clone()) + .expect("resolved information edit must have a live journal cause"); + Ok(Some(command)) + } + + /// CR 701.20a + CR 400.7: Apply one exact information-boundary transition + /// without re-evaluating the effect that disclosed the card. + pub fn apply_resolved_information( + &mut self, + command: &ResolvedInformationCommand, + ) -> Result<(), ResolvedInformationReplayInvariantError> { + self.apply_information_edit( + &command.occurrences, + command.audience, + command.lifetime, + command.edit, + ) + } + + /// CR 400.7: Clear object-incarnation-scoped reveal state at the zone + /// boundary. The future Zone command will invoke this same final mutation + /// primitive while owning the enclosing zone-change command and journal + /// entry; this legacy boundary intentionally does not create one itself. + pub(crate) fn clear_revealed_information_on_zone_exit( + &mut self, + occurrence: ObjectIncarnationRef, + ) { + let controller = self + .objects + .get(&occurrence.object_id) + .map(|object| object.controller) + .expect("zone-exit reveal clear must reference a live object"); + for (audience, lifetime) in [ + ( + ResolvedInformationAudience::Controller(controller), + ResolvedInformationLifetime::UntilActionBoundary, + ), + ( + ResolvedInformationAudience::Public, + ResolvedInformationLifetime::UntilZoneChange, + ), + ] { + let active = match audience { + ResolvedInformationAudience::Controller(_) => { + self.revealed_cards.contains(&occurrence.object_id) + } + ResolvedInformationAudience::Public => { + self.public_revealed_cards.contains(&occurrence.object_id) + } + }; + if active { + self.apply_information_edit( + &[occurrence], + audience, + lifetime, + ResolvedInformationEdit::Hide, + ) + .expect("zone-exit reveal clear must match the live object occurrence"); + } + } + } + + fn apply_information_edit( + &mut self, + occurrences: &[ObjectIncarnationRef], + audience: ResolvedInformationAudience, + lifetime: ResolvedInformationLifetime, + edit: ResolvedInformationEdit, + ) -> Result<(), ResolvedInformationReplayInvariantError> { + let valid_lifetime = matches!( + (audience, lifetime), + ( + ResolvedInformationAudience::Controller(_), + ResolvedInformationLifetime::UntilActionBoundary + ) | ( + ResolvedInformationAudience::Public, + ResolvedInformationLifetime::UntilZoneChange + ) + ); + if !valid_lifetime { + return Err( + ResolvedInformationReplayInvariantError::InvalidAudienceLifetime { + audience, + lifetime, + }, + ); + } + if occurrences.is_empty() { + return Err(ResolvedInformationReplayInvariantError::EmptyOccurrences); + } + + let mut seen = HashSet::new(); + for occurrence in occurrences { + if !seen.insert(occurrence.object_id) { + return Err( + ResolvedInformationReplayInvariantError::DuplicateOccurrence(*occurrence), + ); + } + let object = self.objects.get(&occurrence.object_id).ok_or( + ResolvedInformationReplayInvariantError::MissingObject(*occurrence), + )?; + let found = ObjectIncarnationRef::from_object(object); + if found != *occurrence { + return Err(ResolvedInformationReplayInvariantError::StaleObject { + expected: *occurrence, + found, + }); + } + let active = match audience { + ResolvedInformationAudience::Controller(_) => { + self.revealed_cards.contains(&occurrence.object_id) + } + ResolvedInformationAudience::Public => { + self.public_revealed_cards.contains(&occurrence.object_id) + } + }; + match edit { + ResolvedInformationEdit::Reveal if active => { + return Err( + ResolvedInformationReplayInvariantError::RevealAlreadyActive(*occurrence), + ); + } + ResolvedInformationEdit::Hide if !active => { + return Err( + ResolvedInformationReplayInvariantError::HideWithoutActiveReveal( + *occurrence, + ), + ); + } + ResolvedInformationEdit::Reveal | ResolvedInformationEdit::Hide => {} + } + } + + let revealed_cards = match audience { + ResolvedInformationAudience::Controller(_) => &mut self.revealed_cards, + ResolvedInformationAudience::Public => &mut self.public_revealed_cards, + }; + for occurrence in occurrences { + match edit { + ResolvedInformationEdit::Reveal => { + revealed_cards.insert(occurrence.object_id); + } + ResolvedInformationEdit::Hide => { + revealed_cards.remove(&occurrence.object_id); + } + } + } + Ok(()) + } + /// CR 106.4: Apply one exact, already-resolved mana insertion without /// allocating a replacement pip or consulting mana-production state. pub fn apply_resolved_mana_insert( @@ -17510,6 +17710,183 @@ impl PartialEq for GameState { impl Eq for GameState {} +#[cfg(test)] +mod resolved_information_tests { + use super::*; + use crate::game::effects; + use crate::game::zones::create_object; + use crate::types::ability::{ + CardSelectionMode, Effect, QuantityExpr, ResolvedAbility, RevealUntilDisposition, + TargetFilter, TargetRef, + }; + use crate::types::identifiers::CardId; + use crate::types::resolved_commands::{ + ResolvedInformationAudience, ResolvedInformationEdit, ResolvedInformationLifetime, + ResolvedPlayerEdit, ResolvedRulesCommand, + }; + + #[test] + fn reveal_hand_resolves_through_information_command_and_replays_with_player_edit() { + let mut state = GameState::new_two_player(42); + let revealed = create_object( + &mut state, + CardId(1), + PlayerId(1), + "Revealed Hand Card".to_string(), + Zone::Hand, + ); + let ability = ResolvedAbility::new( + Effect::RevealHand { + target: TargetFilter::Any, + card_filter: TargetFilter::Any, + count: None, + selection: CardSelectionMode::Chosen, + choice_optional: false, + reveal: true, + }, + vec![TargetRef::Player(PlayerId(1))], + ObjectId(100), + PlayerId(0), + ); + let pre_state = state.clone(); + let mut events = Vec::new(); + + effects::resolve_effect(&mut state, &ability, &mut events).unwrap(); + + assert!(state.revealed_cards.contains(&revealed)); + assert!(state.public_revealed_cards.contains(&revealed)); + assert!(events.iter().any(|event| matches!( + event, + GameEvent::CardsRevealed { card_ids, .. } if card_ids == &vec![revealed] + ))); + assert!(state + .resolve_and_apply_information( + &[revealed], + ResolvedInformationAudience::Controller(PlayerId(0)), + ResolvedInformationLifetime::UntilActionBoundary, + ResolvedInformationEdit::Reveal, + ) + .unwrap() + .is_none()); + assert!(state + .resolve_and_apply_information( + &[revealed], + ResolvedInformationAudience::Public, + ResolvedInformationLifetime::UntilZoneChange, + ResolvedInformationEdit::Reveal, + ) + .unwrap() + .is_none()); + + state + .resolve_and_apply_player_edit(PlayerId(0), ResolvedPlayerEdit::Life { delta: -2 }) + .unwrap(); + state + .resolve_and_apply_information( + &[revealed], + ResolvedInformationAudience::Controller(PlayerId(0)), + ResolvedInformationLifetime::UntilActionBoundary, + ResolvedInformationEdit::Hide, + ) + .unwrap() + .expect("the active reveal lease must record one hide command"); + let information = state + .resolved_rules_journal + .entries() + .iter() + .filter_map(|entry| match &entry.command { + Some(ResolvedRulesCommand::Information(command)) => Some(command.clone()), + _ => None, + }) + .collect::>(); + assert_eq!( + information.len(), + 3, + "the real reveal, its public disclosure, and final hide each record one command" + ); + assert!(matches!( + information[0].audience, + ResolvedInformationAudience::Controller(PlayerId(0)) + )); + assert!(matches!( + information[1].audience, + ResolvedInformationAudience::Public + )); + let player_edit = state + .resolved_rules_journal + .entries() + .iter() + .find_map(|entry| match &entry.command { + Some(ResolvedRulesCommand::PlayerEdit(command)) => Some(command.clone()), + _ => None, + }) + .expect("cross-family player edit must be recorded"); + + let mut replay = pre_state; + replay.apply_resolved_information(&information[0]).unwrap(); + replay.apply_resolved_information(&information[1]).unwrap(); + replay.apply_resolved_player_edit(&player_edit).unwrap(); + replay.apply_resolved_information(&information[2]).unwrap(); + + assert_eq!(replay.revealed_cards, state.revealed_cards); + assert_eq!(replay.public_revealed_cards, state.public_revealed_cards); + assert_eq!(replay.players[0].life, state.players[0].life); + } + + #[test] + fn reveal_until_reveal_only_records_exact_retained_information() { + let mut state = GameState::new_two_player(42); + let revealed = create_object( + &mut state, + CardId(2), + PlayerId(0), + "Retained Library Card".to_string(), + Zone::Library, + ); + let ability = ResolvedAbility::new( + Effect::RevealUntil { + player: TargetFilter::Controller, + filter: TargetFilter::Any, + count: QuantityExpr::Fixed { value: 1 }, + matched_disposition: RevealUntilDisposition::RevealOnly, + kept_destination: Zone::Library, + rest_destination: Zone::Library, + enter_tapped: EtbTapState::Unspecified, + enters_attacking: false, + kept_optional_to: None, + enters_under: None, + }, + vec![], + ObjectId(101), + PlayerId(0), + ); + let pre_state = state.clone(); + let mut events = Vec::new(); + + effects::resolve_effect(&mut state, &ability, &mut events).unwrap(); + + assert!(state.revealed_cards.contains(&revealed)); + assert!(state.public_revealed_cards.contains(&revealed)); + let information = state + .resolved_rules_journal + .entries() + .iter() + .filter_map(|entry| match &entry.command { + Some(ResolvedRulesCommand::Information(command)) => Some(command.clone()), + _ => None, + }) + .collect::>(); + assert_eq!(information.len(), 2); + + let mut replay = pre_state; + for command in &information { + replay.apply_resolved_information(command).unwrap(); + } + assert_eq!(replay.revealed_cards, state.revealed_cards); + assert_eq!(replay.public_revealed_cards, state.public_revealed_cards); + } +} + #[cfg(test)] mod active_search_provenance_tests { use super::*; diff --git a/crates/engine/src/types/resolved_commands.rs b/crates/engine/src/types/resolved_commands.rs index bbad3670a6..f0684a0a1b 100644 --- a/crates/engine/src/types/resolved_commands.rs +++ b/crates/engine/src/types/resolved_commands.rs @@ -140,6 +140,48 @@ pub struct ResolvedObjectCounterCommand { pub cause: RulesExecutionNodeRef, } +/// The audience that received one exact revealed-card fact. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ResolvedInformationAudience { + /// CR 701.20a: The controller's active reveal lease, retained only while + /// the resolving instruction still needs the revealed card. + Controller(PlayerId), + /// CR 701.20a: A fact that has been published to every player. + Public, +} + +/// The precise lifetime of one revealed-card fact. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ResolvedInformationLifetime { + /// CR 701.20a: The reveal remains available through the current effect or + /// prompt and is cleared at the next applicable action boundary. + UntilActionBoundary, + /// CR 400.7: The published fact belongs to this object incarnation and + /// expires when that object changes zones. + UntilZoneChange, +} + +/// The final information-boundary transition for exact object occurrences. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ResolvedInformationEdit { + Reveal, + Hide, +} + +/// One resolved reveal or hide transition after all card selection is settled. +/// +/// `occurrences` deliberately stores exact object incarnations rather than raw +/// `ObjectId`s: CR 400.7 makes a zone-changed object a new object even when the +/// engine reuses its storage id. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResolvedInformationCommand { + pub occurrences: Vec, + pub audience: ResolvedInformationAudience, + pub lifetime: ResolvedInformationLifetime, + pub edit: ResolvedInformationEdit, + pub cause: RulesExecutionNodeRef, +} + /// One exact constrained-trigger ledger fact. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum ResolvedTriggerLedgerEdit { @@ -232,6 +274,7 @@ pub enum ResolvedRulesCommand { PlayerEdit(ResolvedPlayerEditCommand), ObjectStatus(ResolvedObjectStatusCommand), ObjectCounter(ResolvedObjectCounterCommand), + Information(ResolvedInformationCommand), LedgerEdit(ResolvedLedgerEditCommand), LibraryShuffle(ResolvedLibraryShuffleCommand), } @@ -514,6 +557,60 @@ impl std::fmt::Display for ResolvedObjectCounterReplayInvariantError { impl std::error::Error for ResolvedObjectCounterReplayInvariantError {} +/// Typed failure while applying an exact revealed-information command. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResolvedInformationReplayInvariantError { + EmptyOccurrences, + DuplicateOccurrence(ObjectIncarnationRef), + MissingObject(ObjectIncarnationRef), + StaleObject { + expected: ObjectIncarnationRef, + found: ObjectIncarnationRef, + }, + RevealAlreadyActive(ObjectIncarnationRef), + HideWithoutActiveReveal(ObjectIncarnationRef), + InvalidAudienceLifetime { + audience: ResolvedInformationAudience, + lifetime: ResolvedInformationLifetime, + }, +} + +impl std::fmt::Display for ResolvedInformationReplayInvariantError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::EmptyOccurrences => write!(f, "resolved information command has no occurrences"), + Self::DuplicateOccurrence(occurrence) => { + write!(f, "resolved information command repeats {occurrence:?}") + } + Self::MissingObject(occurrence) => { + write!(f, "resolved information command cannot find {occurrence:?}") + } + Self::StaleObject { expected, found } => write!( + f, + "resolved information command expected {expected:?}, found {found:?}" + ), + Self::RevealAlreadyActive(occurrence) => { + write!( + f, + "resolved information command reveals active {occurrence:?}" + ) + } + Self::HideWithoutActiveReveal(occurrence) => { + write!( + f, + "resolved information command hides inactive {occurrence:?}" + ) + } + Self::InvalidAudienceLifetime { audience, lifetime } => write!( + f, + "resolved information command has incompatible {audience:?} and {lifetime:?}" + ), + } + } +} + +impl std::error::Error for ResolvedInformationReplayInvariantError {} + /// Typed failure while applying an already-resolved per-event ledger command. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ResolvedLedgerEditReplayInvariantError { @@ -995,6 +1092,14 @@ impl ResolvedRulesJournal { self.append_command(command.cause, ResolvedRulesCommand::ObjectCounter(command)) } + /// Records one exact information-boundary transition under its causal node. + pub fn record_information( + &mut self, + command: ResolvedInformationCommand, + ) -> Result { + self.append_command(command.cause, ResolvedRulesCommand::Information(command)) + } + /// Records one exact semantic ledger mutation under its causal node. pub fn record_ledger_edit( &mut self, @@ -1209,6 +1314,7 @@ impl ResolvedRulesJournal { ResolvedRulesCommand::PlayerEdit(_) | ResolvedRulesCommand::ObjectStatus(_) | ResolvedRulesCommand::ObjectCounter(_) + | ResolvedRulesCommand::Information(_) | ResolvedRulesCommand::LedgerEdit(_) | ResolvedRulesCommand::LibraryShuffle(_) => {} } @@ -1414,6 +1520,14 @@ impl ResolvedRulesJournal { } } } + ResolvedRulesCommand::Information(command) => { + if entry.node != command.cause || information_command_is_invalid(command) { + return Err(ResolvedRulesJournalError::InvalidSerializedAuthority( + "information command has an invalid occurrence, audience, lifetime, or cause" + .to_string(), + )); + } + } ResolvedRulesCommand::LedgerEdit(command) => { if entry.node != command.cause || ledger_edit_is_invalid(&command.edit) @@ -1486,6 +1600,25 @@ fn object_counter_edit_is_empty(edit: &ResolvedObjectCounterEdit) -> bool { } } +fn information_command_is_invalid(command: &ResolvedInformationCommand) -> bool { + let valid_lifetime = matches!( + (command.audience, command.lifetime), + ( + ResolvedInformationAudience::Controller(_), + ResolvedInformationLifetime::UntilActionBoundary + ) | ( + ResolvedInformationAudience::Public, + ResolvedInformationLifetime::UntilZoneChange + ) + ); + let mut object_ids = HashSet::new(); + command.occurrences.is_empty() + || !valid_lifetime + || command.occurrences.iter().any(|occurrence| { + occurrence.incarnation == LEGACY_INCARNATION || !object_ids.insert(occurrence.object_id) + }) +} + fn ledger_edit_is_invalid(edit: &ResolvedLedgerEdit) -> bool { match edit { ResolvedLedgerEdit::SpellCast { @@ -1891,6 +2024,78 @@ mod tests { .is_err()); } + #[test] + fn information_commands_roundtrip_and_reject_malformed_payloads() { + let mut journal = ResolvedRulesJournal::default(); + let cause = journal.begin_proposal().unwrap(); + let occurrence = ObjectIncarnationRef::of(ObjectId(9), 2); + journal + .record_information(ResolvedInformationCommand { + occurrences: vec![occurrence], + audience: ResolvedInformationAudience::Controller(PlayerId(0)), + lifetime: ResolvedInformationLifetime::UntilActionBoundary, + edit: ResolvedInformationEdit::Reveal, + cause, + }) + .unwrap(); + journal + .record_information(ResolvedInformationCommand { + occurrences: vec![occurrence], + audience: ResolvedInformationAudience::Public, + lifetime: ResolvedInformationLifetime::UntilZoneChange, + edit: ResolvedInformationEdit::Reveal, + cause, + }) + .unwrap(); + assert_eq!( + serde_json::from_value::(serde_json::to_value(&journal).unwrap()) + .unwrap(), + journal + ); + + let mut empty = journal.clone(); + let Some(ResolvedRulesCommand::Information(command)) = empty.entries[1].command.as_mut() + else { + panic!("entry 1 must be the controller information command"); + }; + command.occurrences.clear(); + assert!(serde_json::from_value::( + serde_json::to_value(empty).unwrap() + ) + .is_err()); + + let mut invalid_lifetime = journal.clone(); + let Some(ResolvedRulesCommand::Information(command)) = + invalid_lifetime.entries[2].command.as_mut() + else { + panic!("entry 2 must be the public information command"); + }; + command.lifetime = ResolvedInformationLifetime::UntilActionBoundary; + assert!(serde_json::from_value::( + serde_json::to_value(invalid_lifetime).unwrap() + ) + .is_err()); + + let mut legacy_occurrence = journal.clone(); + let Some(ResolvedRulesCommand::Information(command)) = + legacy_occurrence.entries[1].command.as_mut() + else { + panic!("entry 1 must be the controller information command"); + }; + command.occurrences[0].incarnation = LEGACY_INCARNATION; + assert!(serde_json::from_value::( + serde_json::to_value(legacy_occurrence).unwrap() + ) + .is_err()); + + let mut missing_lifetime = serde_json::to_value(&journal).unwrap(); + missing_lifetime["entries"][1]["command"]["Information"] + .as_object_mut() + .unwrap() + .remove("lifetime"); + assert!(serde_json::from_value::(missing_lifetime).is_err()); + } + #[test] fn counter_and_ledger_commands_roundtrip_and_reject_malformed_payloads() { let mut journal = ResolvedRulesJournal::default(); diff --git a/crates/engine/tests/integration/cr733_resolved_commands_p2.rs b/crates/engine/tests/integration/cr733_resolved_commands_p2.rs index 303fbc167d..fc2c5b8c03 100644 --- a/crates/engine/tests/integration/cr733_resolved_commands_p2.rs +++ b/crates/engine/tests/integration/cr733_resolved_commands_p2.rs @@ -83,6 +83,9 @@ fn apply_semantic_command(state: &mut GameState, command: &ResolvedRulesCommand) engine::game::library::apply_resolved_library_shuffle(state, command, &mut Vec::new()) .unwrap(); } + ResolvedRulesCommand::Information(command) => { + state.apply_resolved_information(command).unwrap(); + } } } @@ -169,9 +172,8 @@ fn exact_mana_spend_rejects_a_second_removal() { | ResolvedRulesCommand::ObjectStatus(_) | ResolvedRulesCommand::ObjectCounter(_) | ResolvedRulesCommand::LedgerEdit(_) - | ResolvedRulesCommand::LibraryShuffle(_) => { - apply_semantic_command(&mut replay, command) - } + | ResolvedRulesCommand::LibraryShuffle(_) + | ResolvedRulesCommand::Information(_) => apply_semantic_command(&mut replay, command), } } assert!(