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
91 changes: 43 additions & 48 deletions crates/engine/src/game/effects/draw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -427,13 +427,11 @@ pub fn apply_draw_after_replacement(
// `select_cards_to_draw` authority so a `DrawFromBottom` static is honored.
let cards_to_draw = select_cards_to_draw(state, player_id, allowed_count as usize);

// CR 704.5b: If library has fewer cards than requested, mark the player.
// CR 121.4: Partial draws are legal — draw what's available.
if allowed_count > 0 && cards_to_draw.len() < allowed_count as usize {
if let Some(player) = state.players.iter_mut().find(|p| p.id == player_id) {
player.drew_from_empty_library = true;
}
}
// CR 704.5b: If library has fewer cards than requested, the ledger edit for
// this settled draw owns the player's empty-library fact. CR 121.4: partial
// draws are legal — draw what's available.
let mut attempted_empty_library =
allowed_count > 0 && cards_to_draw.len() < allowed_count as usize;

let drawn_count = cards_to_draw.len() as u32;

Expand Down Expand Up @@ -493,63 +491,60 @@ pub fn apply_draw_after_replacement(
a swallowed NeedsChoice or otherwise failed to deliver, yet CardDrawn \
and the draw counters fire below"
);
// CR 121.1 + CR 504.1: Increment per-step + per-turn counters BEFORE
// emitting the event so the ordinal embedded in `CardDrawn` reflects
// this draw (1-indexed). Triggers/replacements that gate on "first
// draw of the draw step" read this ordinal.
let drawn_object = crate::types::identifiers::ObjectIncarnationRef::from_object(
state
.objects
.get(&obj_id)
.expect("settled draw object remains live for its ledger edit"),
);
let established_first_draw = crate::game::ledger::resolve_and_apply_cards_drawn(
state,
player_id,
Some(drawn_object),
std::mem::take(&mut attempted_empty_library),
)
.expect("settled draw bookkeeping must have a live player and journal cause");
// CR 121.1 + CR 504.1: The exact ledger edit increments the counters
// before `CardDrawn` is emitted, so its ordinal is 1-indexed.
let player = state
.players
.iter()
.find(|player| player.id == player_id)
.expect("settled draw player remains live after its ledger edit");
let (nth_in_turn, nth_in_step) =
if let Some(player) = state.players.iter_mut().find(|p| p.id == player_id) {
// CR 121.1: This driver is the single authority for every
// settled draw, so it is the single place that marks the
// player as having drawn a card this turn — broadened from
// the pre-migration "took the draw-step draw" reading (the
// only production setter, deleted by the turns.rs/gift
// migration onto this driver) to "drew at least one card
// this turn". No production reader distinguishes the two;
// `turns.rs` clears it at turn start and
// `analysis/resource.rs` ignores it entirely.
player.has_drawn_this_turn = true;
player.cards_drawn_this_turn = player.cards_drawn_this_turn.saturating_add(1);
player.cards_drawn_this_step = player.cards_drawn_this_step.saturating_add(1);
(player.cards_drawn_this_turn, player.cards_drawn_this_step)
} else {
(1, 1)
};
(player.cards_drawn_this_turn, player.cards_drawn_this_step);
events.push(GameEvent::CardDrawn {
player_id,
object_id: obj_id,
nth_in_turn,
nth_in_step,
});
super::drawn_this_turn_choice::record_drawn_card(state, player_id, obj_id);
record_first_draw_and_enqueue_miracle(state, player_id, obj_id);
if established_first_draw {
enqueue_miracle_offer_for_first_draw(state, player_id, obj_id);
}
}

if attempted_empty_library {
crate::game::ledger::resolve_and_apply_cards_drawn(state, player_id, None, true)
.expect("empty-library draw bookkeeping must have a live player and journal cause");
}

drawn_count
}

/// CR 702.94a + CR 603.11: Shared first-draw hook — record the drawn
/// `ObjectId` as `player`'s first-of-turn if absent, and if the drawn card has
/// `Keyword::Miracle(cost)`, enqueue a `MiracleOffer` for the priority-entry
/// flush to surface as `WaitingFor::MiracleReveal`. Subsequent draws do NOT
/// overwrite the first-draw entry and do NOT enqueue more offers (the static
/// ability only functions for the first-drawn card per CR 702.94a).
pub(crate) fn record_first_draw_and_enqueue_miracle(
/// CR 702.94a + CR 603.11: Continuation-only first-draw hook. The ledger edit
/// has already recorded `object_id` as this player's first card drawn this
/// turn; this function only enqueues the deferred miracle offer.
fn enqueue_miracle_offer_for_first_draw(
state: &mut GameState,
player: crate::types::player::PlayerId,
object_id: crate::types::identifiers::ObjectId,
) {
// Only the FIRST draw of the turn per player establishes the miracle
// eligibility condition. `or_insert_with` returns a `&mut V` indicating
// whether the entry was freshly set; compare against `object_id` to know.
let is_first = !state.first_card_drawn_this_turn.contains_key(&player);
state
.first_card_drawn_this_turn
.entry(player)
.or_insert(object_id);
if !is_first {
return;
}
debug_assert_eq!(
state.first_card_drawn_this_turn.get(&player),
Some(&object_id),
"first-draw continuation must follow the matching ledger edit"
);
let Some(obj) = state.objects.get(&object_id) else {
return;
};
Expand Down
174 changes: 171 additions & 3 deletions crates/engine/src/game/ledger.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@

use crate::types::ability::TriggerDefinitionRef;
use crate::types::game_state::{GameState, SpellCastRecord};
use crate::types::identifiers::ObjectId;
use crate::types::identifiers::{ObjectId, ObjectIncarnationRef};
use crate::types::player::PlayerId;
use crate::types::resolved_commands::{
ResolvedLedgerEdit, ResolvedLedgerEditCommand, ResolvedLedgerEditReplayInvariantError,
ResolvedOncePerTurnPermission, ResolvedTriggerLedgerEdit,
ledger_edit_is_invalid, ResolvedLedgerEdit, ResolvedLedgerEditCommand,
ResolvedLedgerEditReplayInvariantError, ResolvedOncePerTurnPermission,
ResolvedTriggerLedgerEdit,
};
use crate::types::zones::Zone;

/// Constructs, applies, and journals one exact semantic ledger edit.
///
Expand Down Expand Up @@ -114,6 +116,83 @@ pub fn consume_once_per_turn_permission(
)
}

/// CR 121.1 + CR 121.2 + CR 121.4: Capture and install one post-replacement
/// draw's exact bookkeeping. The zone-change hub has already installed
/// `drawn_object` before this ledger command is recorded; replay therefore
/// never selects a library card or re-runs replacement effects.
///
/// `drawn_object` is absent only when an attempted draw found an empty library.
/// The returned boolean tells the continuation-only miracle hook whether this
/// command established the player's first draw of the turn.
pub fn resolve_and_apply_cards_drawn(
state: &mut GameState,
player: PlayerId,
drawn_object: Option<ObjectIncarnationRef>,
attempted_empty_library: bool,
) -> Result<bool, ResolvedLedgerEditReplayInvariantError> {
let player_state = state
.players
.iter()
.find(|candidate| candidate.id == player)
.ok_or(ResolvedLedgerEditReplayInvariantError::UnknownPlayer(
player,
))?;
let expected_drawn_cards_len = history_len(
state
.cards_drawn_this_turn
.get(&player)
.map_or(0, |drawn_cards| drawn_cards.len()),
)?;
let settled_card = drawn_object.is_some();
let expected_first_card_drawn_this_turn =
state.first_card_drawn_this_turn.get(&player).copied();
let resulting_first_card_drawn_this_turn =
expected_first_card_drawn_this_turn.or_else(|| drawn_object.map(|object| object.object_id));
let established_first_draw = settled_card && expected_first_card_drawn_this_turn.is_none();
let resulting_drawn_cards_len = if settled_card {
expected_drawn_cards_len
.checked_add(1)
.ok_or(ResolvedLedgerEditReplayInvariantError::CounterOverflow)?
} else {
expected_drawn_cards_len
};

resolve_and_apply_ledger_edit(
state,
ResolvedLedgerEdit::CardsDrawn {
player,
drawn_object,
attempted_empty_library,
expected_has_drawn_this_turn: player_state.has_drawn_this_turn,
resulting_has_drawn_this_turn: if settled_card {
true
} else {
player_state.has_drawn_this_turn
},
expected_cards_drawn_this_turn: player_state.cards_drawn_this_turn,
resulting_cards_drawn_this_turn: if settled_card {
player_state.cards_drawn_this_turn.saturating_add(1)
} else {
player_state.cards_drawn_this_turn
},
expected_cards_drawn_this_step: player_state.cards_drawn_this_step,
resulting_cards_drawn_this_step: if settled_card {
player_state.cards_drawn_this_step.saturating_add(1)
} else {
player_state.cards_drawn_this_step
},
expected_drew_from_empty_library: player_state.drew_from_empty_library,
resulting_drew_from_empty_library: player_state.drew_from_empty_library
|| attempted_empty_library,
expected_drawn_cards_len,
resulting_drawn_cards_len,
expected_first_card_drawn_this_turn,
resulting_first_card_drawn_this_turn,
},
)?;
Ok(established_first_draw)
}

/// Applies one exact ledger edit without an event dispatcher, replacement
/// pipeline, allocator, or dynamic permission lookup.
pub fn apply_resolved_ledger_edit(
Expand Down Expand Up @@ -216,6 +295,95 @@ pub fn apply_resolved_ledger_edit(
.activated_abilities_this_game
.insert(key, next_game_count);
}
ResolvedLedgerEdit::CardsDrawn {
player,
drawn_object,
expected_has_drawn_this_turn,
resulting_has_drawn_this_turn,
expected_cards_drawn_this_turn,
resulting_cards_drawn_this_turn,
expected_cards_drawn_this_step,
resulting_cards_drawn_this_step,
expected_drew_from_empty_library,
resulting_drew_from_empty_library,
expected_drawn_cards_len,
expected_first_card_drawn_this_turn,
resulting_first_card_drawn_this_turn,
..
} => {
if ledger_edit_is_invalid(&command.edit) {
return Err(ResolvedLedgerEditReplayInvariantError::CardsDrawnPreconditionMismatch);
}
let Some(player_index) = state
.players
.iter()
.position(|candidate| candidate.id == *player)
else {
return Err(ResolvedLedgerEditReplayInvariantError::UnknownPlayer(
*player,
));
};
let current_drawn_cards_len = history_len(
state
.cards_drawn_this_turn
.get(player)
.map_or(0, |drawn_cards| drawn_cards.len()),
)?;
let current_first_card_drawn_this_turn =
state.first_card_drawn_this_turn.get(player).copied();
let player_state = &state.players[player_index];
if player_state.has_drawn_this_turn != *expected_has_drawn_this_turn
|| player_state.cards_drawn_this_turn != *expected_cards_drawn_this_turn
|| player_state.cards_drawn_this_step != *expected_cards_drawn_this_step
|| player_state.drew_from_empty_library != *expected_drew_from_empty_library
|| current_drawn_cards_len != *expected_drawn_cards_len
|| current_first_card_drawn_this_turn != *expected_first_card_drawn_this_turn
{
return Err(ResolvedLedgerEditReplayInvariantError::CardsDrawnPreconditionMismatch);
}
if let Some(expected) = drawn_object {
let found = state
.objects
.get(&expected.object_id)
.map(ObjectIncarnationRef::from_object);
if found != Some(*expected) {
return Err(
ResolvedLedgerEditReplayInvariantError::DrawnObjectMismatch {
expected: *expected,
found,
},
);
}
if state.objects[&expected.object_id].zone == Zone::Library {
return Err(
ResolvedLedgerEditReplayInvariantError::DrawnObjectStillInLibrary(
*expected,
),
);
}
}

let player_state = &mut state.players[player_index];
player_state.has_drawn_this_turn = *resulting_has_drawn_this_turn;
player_state.cards_drawn_this_turn = *resulting_cards_drawn_this_turn;
player_state.cards_drawn_this_step = *resulting_cards_drawn_this_step;
player_state.drew_from_empty_library = *resulting_drew_from_empty_library;
if let Some(object) = drawn_object {
crate::game::effects::drawn_this_turn_choice::record_drawn_card(
state,
*player,
object.object_id,
);
}
match resulting_first_card_drawn_this_turn {
Some(object) => {
state.first_card_drawn_this_turn.insert(*player, *object);
}
None => {
state.first_card_drawn_this_turn.remove(player);
}
}
}
ResolvedLedgerEdit::TriggerFired { trigger, edit } => match edit {
ResolvedTriggerLedgerEdit::OncePerTurn => {
if !state.triggers_fired_this_turn.insert(trigger.clone()) {
Expand Down
Loading
Loading