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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions crates/engine/src/game/effects/reveal_hand.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<String> = hand
Expand Down
65 changes: 56 additions & 9 deletions crates/engine/src/game/effects/reveal_until.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -139,6 +139,31 @@ pub fn resolve(
let mut all_revealed: Vec<ObjectId> = 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<String> = all_revealed
.iter()
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand All @@ -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<String> = revealed
.iter()
Expand Down
51 changes: 47 additions & 4 deletions crates/engine/src/game/engine.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand All @@ -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;

Expand Down Expand Up @@ -292,6 +296,7 @@ pub(super) fn apply_action_boundary_with_stack_limit(
) -> Result<ActionResult, EngineError> {
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();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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::<HashSet<_>>();

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::<Vec<_>>();
state
.resolve_and_apply_information(
&unpublished,
ResolvedInformationAudience::Public,
ResolvedInformationLifetime::UntilZoneChange,
ResolvedInformationEdit::Reveal,
)
.expect("published reveal occurrences must be live and distinct");
}
}
}
Expand Down
36 changes: 27 additions & 9 deletions crates/engine/src/game/engine_resolution_choices.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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() {
Expand Down
25 changes: 23 additions & 2 deletions crates/engine/src/game/engine_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 9 additions & 9 deletions crates/engine/src/game/zones.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,15 +136,15 @@ pub(crate) fn apply_zone_exit_cleanup(
attachments: Vec<crate::types::game_state::AttachmentSnapshot>,
) {
// 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
Expand Down
Loading
Loading