diff --git a/.agents/cr733/RUN7-REPORT.md b/.agents/cr733/RUN7-REPORT.md new file mode 100644 index 0000000000..c0b8fd46b3 --- /dev/null +++ b/.agents/cr733/RUN7-REPORT.md @@ -0,0 +1,148 @@ +# CR733 resolved-command journal — Run 7 P2 tranche 3 report + +## Starting state + +Confirmed before edits: `/Users/matt/dev/forge.rs-cr733`, branch +`cr733/resolved-commands`, `HEAD` +`a1984d3c5b2e830819812d2dc90f4810c79fd741`, and no tracked modifications. +The required prerequisite plan, Run 5/6 reports, P2 mana/scalar-status commits, +and CR733 authority matrix were read before implementation. + +## Landed implementation + +- `b905f7e85e` — `cr733(p2): apply resolved counter and ledger commands` + +## P2 recipe coverage + +### Counters + +1. Player counters remain owned by the pre-existing + `ResolvedPlayerEdit::Counter` authority. This tranche adds only object-counter + delivery; it does not duplicate energy, experience, ticket, or other player + counter handling. +2. `ResolvedObjectCounterCommand` carries the exact `ObjectIncarnationRef`, + `CounterType`, captured predecessor count, final add/remove count, counter-add + actor, and causal node. Its precondition makes an exact delivery + non-idempotent. +3. `game::effects::counters::apply_counter_addition` and + `apply_counter_removal` construct commands only at their final delivery seam. + Addition is reached after `add_counter_with_replacement` has completed the + CR 614 replacement path, so the command records the final Vorinclex/Hardened + Scales-class count and never re-enters replacement processing on replay. +4. The shared applier is: + + ```rust + game::effects::counters::apply_resolved_counter_edit( + state: &mut GameState, + command: &ResolvedObjectCounterCommand, + ) -> Result<(), ResolvedObjectCounterReplayInvariantError> + ``` + + It validates object existence, exact incarnation, expected predecessor count, + nonzero count, and overflow before changing the map. A stale occurrence or + repeated command fails typed; it never partially mutates state. Counter-added + history and layer dirtiness remain inside this final authority. Existing event + emission stays at the ordinary caller after a successful command application. +5. Ordinary zero additions/removals remain no-ops: no command, map edit, or + event is created. An inline regression test covers that path. +6. The integration test casts source-verified **Stony Strength** through + `GameRunner`, replays its recorded object-counter command from the pre-state, + and compares counters plus counter history. A hostile test proves both a + double application and a same-ID new incarnation are typed failures. + +### Ledgers and once-per-* facts + +1. New `game::ledger` is the sole final authority for this tranche's per-event + ledger edits. It records semantic key/append edits rather than replacing any + whole map, set, or history container. +2. `ResolvedLedgerEdit` covers the requested event facts: finalized spell casts, + activated abilities, constrained triggers (once per turn/game/per opponent + and max-times-per-turn), and consumed once-per-turn permission keys. +3. Ordinary spell-cast, activated-ability, and trigger recording now route + through the ledger authority. Cast/play finalization routes all currently + modeled graveyard, hand, alternative-cost, exile, and top-library + once-per-turn permission consumptions through it as well. +4. The appliers are: + + ```rust + game::ledger::resolve_and_apply_ledger_edit( + state: &mut GameState, + edit: ResolvedLedgerEdit, + ) -> Result<(), ResolvedLedgerEditReplayInvariantError> + + game::ledger::apply_resolved_ledger_edit( + state: &mut GameState, + command: &ResolvedLedgerEditCommand, + ) -> Result<(), ResolvedLedgerEditReplayInvariantError> + ``` + + Spell records validate the captured aggregate/map/history prefix before + appending both histories; activation and max-trigger counts validate their + exact predecessor values; set inserts reject duplicates as typed failures. +5. The integration test's real Stony Strength cast also supplies an actual + spell-cast ledger entry. Replaying it from the cast pre-state reproduces its + aggregate and per-player histories; a second application returns + `SpellCastPreconditionMismatch`. + +## Adjudications and wire safety + +- Turn-boundary bulk clears are deliberately **not** journaled here. They remain + the future Turn-transition family's semantic aggregate; this tranche records + only per-event inserts/consumptions. +- Other matrix ledger rows remain out of this narrow tranche, including + commander-cast facts, `spells_cast_last_turn`, ability-resolution/land/damage + and other per-turn ledgers, and unbounded/loop tracking. They require their + own exact semantic command scopes rather than being folded into this one. +- Journal deserialization fail-closes both new families on an unrelated node, + zero/no-op counter edit, impossible counter predecessor, overflowing ledger + prefix, or legacy object identity. `ObjectIncarnationRef`'s inherited + bare-ID compatibility shape produces `LEGACY_INCARNATION`; executable counter + and trigger-ledger payloads now reject it before either applier is reachable. + The unit test mutates each serialized legacy shape and confirms rejection. +- No new serde-default identity field or applier sentinel was introduced. + +## Rules evidence + +Verified before annotations against +`/Users/matt/dev/forge.rs/docs/MagicCompRules.txt`: + +- CR 122.1, 122.1a-g, 122.2, and 122.6/a — counters and counters put on an + object. +- CR 614.1, 614.1a-d, and 614.12 — replacement effects and entry replacement + handling. +- CR 601.2i — a spell becomes cast after casting is complete. +- CR 602.5b — restricted activated abilities remain restricted on that object. +- CR 603.2c — trigger occurrences. + +The Stony Strength test Oracle text was independently read from the Scryfall +card API: “Put a +1/+1 counter on target creature you control. Untap that +creature.” + +## Verification and parity risk + +- `cargo fmt --all` and `git diff --check` passed. +- The implementation commit's parser-combinator and router/grant pre-commit + gates passed. +- Per the binding charter, no Cargo build, clippy run, or test suite was run; + Tilt from another checkout was not used as evidence. +- Confidence is **moderate**. Source inspection confirms that object additions + journal after replacement delivery and that production per-event ledger + writers now enter the shared applier. The unrun integration suite is the + remaining material risk, especially broad replacement and cast-finalization + funnels. + +## Remaining P2 families + +Zone changes; draw/mill/discard/exile/sacrifice/return; library +order/reveal/shuffle; token/object creation; deletion/cleanup; modifier +registries; stack; turn/combat/outcome; trigger/LKI collection; continuation +state; remaining ledger rows; information; and player leave remain outside this +tranche. Do not begin P3 reconstruction. + +## Most important next-run fact + +Every semantic entry—including these counter and ledger commands—must append +under its owning P1 node in the one shared `ResolvedCommandOrdinal` stream. A +future family must replay its final resolved operands through its own authority, +never by re-entering replacement processing, effect dispatch, or a bulk +container write. diff --git a/crates/engine/src/game/casting_costs.rs b/crates/engine/src/game/casting_costs.rs index be7498eadf..494e7e7cbb 100644 --- a/crates/engine/src/game/casting_costs.rs +++ b/crates/engine/src/game/casting_costs.rs @@ -8585,7 +8585,12 @@ fn finalize_cast_with_phyrexian_choices_inner( frequency: crate::types::statics::CastFrequency::OncePerTurn, .. } => { - state.graveyard_cast_permissions_used.insert(source); + crate::game::ledger::consume_once_per_turn_permission( + state, + source, + crate::types::resolved_commands::ResolvedOncePerTurnPermission::GraveyardCast, + ) + .expect("graveyard cast permission must have an unused ledger slot"); } CastingVariant::GraveyardPermission { source, @@ -8594,9 +8599,14 @@ fn finalize_cast_with_phyrexian_choices_inner( .. } => { // CR 110.4: Consume the chosen permanent-type slot for this source. - state - .graveyard_cast_permissions_used_per_type - .insert((source, slot)); + crate::game::ledger::consume_once_per_turn_permission( + state, + source, + crate::types::resolved_commands::ResolvedOncePerTurnPermission::GraveyardCastPermanentType { + permanent_type: slot, + }, + ) + .expect("graveyard permanent-type slot must be unused"); } CastingVariant::GraveyardPermission { frequency: crate::types::statics::CastFrequency::OncePerTurnPerPermanentType, @@ -8613,7 +8623,12 @@ fn finalize_cast_with_phyrexian_choices_inner( source, frequency: crate::types::statics::CastFrequency::OncePerTurn, } => { - state.hand_cast_free_permissions_used.insert(source); + crate::game::ledger::consume_once_per_turn_permission( + state, + source, + crate::types::resolved_commands::ResolvedOncePerTurnPermission::HandCastFree, + ) + .expect("hand cast permission must have an unused ledger slot"); } // CR 601.2a + CR 113.6b: Maralen-class exile-cast permission. Stamp // the per-source slot when the static is `OncePerTurn`; `Unlimited` @@ -8626,7 +8641,12 @@ fn finalize_cast_with_phyrexian_choices_inner( source, frequency: crate::types::statics::CastFrequency::OncePerTurnPerPermanentType, } => { - state.exile_cast_permissions_used.insert(source); + crate::game::ledger::consume_once_per_turn_permission( + state, + source, + crate::types::resolved_commands::ResolvedOncePerTurnPermission::ExileCast, + ) + .expect("exile cast permission must have an unused ledger slot"); } _ => {} } @@ -8639,12 +8659,22 @@ fn finalize_cast_with_phyrexian_choices_inner( // every sibling permission. As Foretold's grant rides `CastingVariant::Normal`, // so the `match casting_variant` above never covers it — this is a separate block. if let Some(src) = alt_cost_grant_source { - state.alt_cost_grant_permissions_used.insert(src); + crate::game::ledger::consume_once_per_turn_permission( + state, + src, + crate::types::resolved_commands::ResolvedOncePerTurnPermission::AlternativeCostGrant, + ) + .expect("alternative-cost grant must have an unused ledger slot"); } if let Some((source, crate::types::statics::CastFrequency::OncePerTurn)) = exile_play_permission_source { - state.exile_play_permissions_used.insert(source); + crate::game::ledger::consume_once_per_turn_permission( + state, + source, + crate::types::resolved_commands::ResolvedOncePerTurnPermission::ExilePlay, + ) + .expect("exile play permission must have an unused ledger slot"); } // CR 601.2a + CR 401.5: Consume the per-turn slot ONLY when the *selected* // authorizing top-of-library permission is `OncePerTurn` (Assemble the @@ -8655,7 +8685,12 @@ fn finalize_cast_with_phyrexian_choices_inner( if let Some((source, crate::types::statics::CastFrequency::OncePerTurn)) = top_of_library_permission_source { - state.top_of_library_cast_permissions_used.insert(source); + crate::game::ledger::consume_once_per_turn_permission( + state, + source, + crate::types::resolved_commands::ResolvedOncePerTurnPermission::TopOfLibraryCast, + ) + .expect("top-of-library cast permission must have an unused ledger slot"); } // CR 601.2a + CR 603.7 + CR 611.2a: A single-use exile-cast grant is spent // on this cast. Record the group and strip the now-void `PlayFromExile` grant from diff --git a/crates/engine/src/game/effects/counters.rs b/crates/engine/src/game/effects/counters.rs index 27744606d2..a5fb28f59f 100644 --- a/crates/engine/src/game/effects/counters.rs +++ b/crates/engine/src/game/effects/counters.rs @@ -17,9 +17,13 @@ use crate::types::game_state::{ PendingCounterMoveQueue, PendingCounterPostAction, PendingCounterRemovalQueue, PendingEffectResolutionEvent, PendingEffectResolved, WaitingFor, }; -use crate::types::identifiers::ObjectId; +use crate::types::identifiers::{ObjectId, ObjectIncarnationRef}; use crate::types::player::PlayerId; use crate::types::proposed_event::{CounterMoveStage, CounterPlacement, ProposedEvent}; +use crate::types::resolved_commands::{ + ResolvedObjectCounterCommand, ResolvedObjectCounterEdit, + ResolvedObjectCounterReplayInvariantError, +}; /// CR 306.5c + CR 310.4c: After mutating the counter map, re-derive the /// `obj.loyalty` / `obj.defense` field so the counter count and the cached @@ -755,50 +759,29 @@ pub(crate) fn apply_counter_addition( return; } - let Some(obj) = state.objects.get_mut(&object_id) else { - return; + let (object, expected_old) = { + let Some(object) = state.objects.get(&object_id) else { + return; + }; + ( + ObjectIncarnationRef::from_object(object), + object.counters.get(&counter_type).copied().unwrap_or(0), + ) }; - - let entry = obj.counters.entry(counter_type.clone()).or_insert(0); - *entry += count; - - // CR 306.5c / CR 310.4c: Keep obj.loyalty / obj.defense in - // sync with the counter map — the field IS the counter count. - sync_derived_from_counters(obj, &counter_type); - - // CR 122.1: Drop stale zero-count keys left over from prior removals before - // recording the object snapshot so counter history never exposes absent - // markers as present entries. - crate::types::counter::prune_zero_counters(&mut obj.counters); - - if counter_type_affects_layers(&counter_type) { - state.layers_dirty.mark_full(); - } - - state.counter_added_this_turn.push(CounterAddedRecord { - actor, - object_id, + let command = ResolvedObjectCounterCommand { + object, counter_type: counter_type.clone(), - count, - name: obj.name.clone(), - core_types: obj.card_types.core_types.clone(), - subtypes: obj.card_types.subtypes.clone(), - supertypes: obj.card_types.supertypes.clone(), - keywords: obj.keywords.clone(), - power: obj.power, - toughness: obj.toughness, - // CR 709.4b + CR 202.3d: combined colors / mana value for a split card off - // the stack (no-op for single-face and battlefield Rooms, which gate out). - colors: obj.effective_colors(), - mana_value: obj.effective_mana_value(), - controller: obj.controller, - owner: obj.owner, - counters: obj - .counters - .iter() - .map(|(ct, n)| (ct.clone(), *n)) - .collect(), - }); + expected_old, + edit: ResolvedObjectCounterEdit::Add { actor, count }, + cause: state.current_or_begin_rules_execution_node(), + }; + if apply_resolved_counter_edit(state, &command).is_err() { + return; + } + state + .resolved_rules_journal + .record_object_counter(command) + .expect("resolved counter addition must have a live journal cause"); events.push(GameEvent::CounterAdded { object_id, @@ -807,6 +790,121 @@ pub(crate) fn apply_counter_addition( }); } +/// CR 122.1 + CR 122.6: Apply one exact post-replacement counter delivery. +/// +/// The command carries the recipient occurrence, prior count, final delivered +/// count, and causal node. This applier never re-enters CR 614's replacement +/// pipeline, so a retained-prefix replay cannot apply Vorinclex/Hardened +/// Scales class replacements twice. +pub fn apply_resolved_counter_edit( + state: &mut GameState, + command: &ResolvedObjectCounterCommand, +) -> Result<(), ResolvedObjectCounterReplayInvariantError> { + let object = state.objects.get(&command.object.object_id).ok_or( + ResolvedObjectCounterReplayInvariantError::MissingObject(command.object), + )?; + let found_reference = ObjectIncarnationRef::from_object(object); + if found_reference != command.object { + return Err(ResolvedObjectCounterReplayInvariantError::StaleObject { + expected: command.object, + found: found_reference, + }); + } + let found_count = object + .counters + .get(&command.counter_type) + .copied() + .unwrap_or(0); + if found_count != command.expected_old { + return Err( + ResolvedObjectCounterReplayInvariantError::CounterPreconditionMismatch { + counter_type: command.counter_type.clone(), + expected: command.expected_old, + found: found_count, + }, + ); + } + + let affects_layers = counter_type_affects_layers(&command.counter_type); + let added_record = { + let object = state.objects.get_mut(&command.object.object_id).ok_or( + ResolvedObjectCounterReplayInvariantError::MissingObject(command.object), + )?; + match &command.edit { + ResolvedObjectCounterEdit::Add { actor, count } => { + if *count == 0 { + return Err(ResolvedObjectCounterReplayInvariantError::ZeroCount); + } + let next = command.expected_old.checked_add(*count).ok_or( + ResolvedObjectCounterReplayInvariantError::CounterOverflow { + counter_type: command.counter_type.clone(), + previous: command.expected_old, + added: *count, + }, + )?; + object.counters.insert(command.counter_type.clone(), next); + sync_derived_from_counters(object, &command.counter_type); + crate::types::counter::prune_zero_counters(&mut object.counters); + Some(CounterAddedRecord { + actor: *actor, + object_id: object.id, + counter_type: command.counter_type.clone(), + count: *count, + name: object.name.clone(), + core_types: object.card_types.core_types.clone(), + subtypes: object.card_types.subtypes.clone(), + supertypes: object.card_types.supertypes.clone(), + keywords: object.keywords.clone(), + power: object.power, + toughness: object.toughness, + // CR 709.4b + CR 202.3d: combined colors / mana value for a + // split card off the stack remain part of the event-time fact. + colors: object.effective_colors(), + mana_value: object.effective_mana_value(), + controller: object.controller, + owner: object.owner, + counters: object + .counters + .iter() + .map(|(counter_type, count)| (counter_type.clone(), *count)) + .collect(), + }) + } + ResolvedObjectCounterEdit::Remove { count } => { + if *count == 0 { + return Err(ResolvedObjectCounterReplayInvariantError::ZeroCount); + } + let next = command.expected_old.checked_sub(*count).ok_or( + ResolvedObjectCounterReplayInvariantError::CounterPreconditionMismatch { + counter_type: command.counter_type.clone(), + expected: command.expected_old, + found: *count, + }, + )?; + object.counters.insert(command.counter_type.clone(), next); + sync_derived_from_counters(object, &command.counter_type); + + // CR 122.1 + CR 306.5c: A drained tracked planeswalker keeps a + // present zero loyalty key so layer re-derivation preserves 0. + let keep_zero = command.counter_type == CounterType::Loyalty && next == 0; + crate::types::counter::prune_zero_counters(&mut object.counters); + if keep_zero { + object.counters.insert(command.counter_type.clone(), 0); + } + None + } + } + }; + + if affects_layers { + state.layers_dirty.mark_full(); + } + if let Some(record) = added_record { + state.counter_added_this_turn.push(record); + } + Ok(()) +} + /// CR 122.1: Apply an already-accepted counter removal, clamping to the number /// actually present and keeping derived counter-backed characteristics in sync. pub(crate) fn apply_counter_removal( @@ -816,54 +914,42 @@ pub(crate) fn apply_counter_removal( count: u32, events: &mut Vec, ) { - let Some(obj) = state.objects.get_mut(&object_id) else { + if count == 0 { return; + } + let (object, expected_old) = { + let Some(object) = state.objects.get(&object_id) else { + return; + }; + ( + ObjectIncarnationRef::from_object(object), + object.counters.get(&counter_type).copied().unwrap_or(0), + ) }; - - let was_present = obj.counters.contains_key(&counter_type); - let entry = obj.counters.entry(counter_type.clone()).or_insert(0); - let removed = (*entry).min(count); - *entry = entry.saturating_sub(count); - let is_zero = *entry == 0; - - // CR 306.5c / CR 310.4c: Keep obj.loyalty / obj.defense in - // sync with the counter map — the field IS the counter count. - sync_derived_from_counters(obj, &counter_type); - - // CR 122.1: Zero-count entries are normally absent — prune so proliferate - // and other "has a counter" checks cannot resurrect removed counter types. - // - // EXCEPTION (CR 306.5c): loyalty is a characteristic-defining counter whose - // field IS the counter count, and the layer system RESETS obj.loyalty to - // base each evaluation then re-derives it from the counter map. Once the - // last loyalty counter is pruned, that re-derive can no longer tell "drained - // to 0" (must die, CR 704.5i) from "not counter-tracked, use the field" - // (a clone whose loyalty comes from the Copy layer). So a genuinely-tracked - // planeswalker drained to exactly 0 must KEEP its 0 entry — the present 0 is - // the signal the layer re-derive needs. A phantom 0 created by `or_insert` - // on a counter that was never present is still pruned, so un-counter-tracked - // objects correctly fall back to their field value. (Defense needs no such - // exception: the layer system never resets obj.defense, so a battle drained - // to 0 keeps defense 0 without help and the CR 704.5v SBA fires normally.) - let keep_zero = was_present && counter_type == CounterType::Loyalty && is_zero; - crate::types::counter::prune_zero_counters(&mut obj.counters); - if keep_zero { - obj.counters.insert(counter_type.clone(), 0); + let removed = expected_old.min(count); + if removed == 0 { + return; } - - if counter_type_affects_layers(&counter_type) { - state.layers_dirty.mark_full(); + let command = ResolvedObjectCounterCommand { + object, + counter_type: counter_type.clone(), + expected_old, + edit: ResolvedObjectCounterEdit::Remove { count: removed }, + cause: state.current_or_begin_rules_execution_node(), + }; + if apply_resolved_counter_edit(state, &command).is_err() { + return; } + state + .resolved_rules_journal + .record_object_counter(command) + .expect("resolved counter removal must have a live journal cause"); - // CR 122.1: Only emit when counters were actually removed, - // matching the semantics of the legacy in-line path. - if removed > 0 { - events.push(GameEvent::CounterRemoved { - object_id, - counter_type, - count: removed, - }); - } + events.push(GameEvent::CounterRemoved { + object_id, + counter_type, + count: removed, + }); } /// CR 601.2h: Resolve a `CounterMatch` cost intent against the counters @@ -2926,6 +3012,33 @@ mod tests { assert_eq!(state.objects[&obj_id].counters[&CounterType::Plus1Plus1], 2); } + #[test] + fn zero_counter_delivery_is_an_ordinary_noop_without_a_command() { + let mut state = GameState::new_two_player(42); + let obj_id = create_object( + &mut state, + CardId(1), + PlayerId(0), + "Creature".to_string(), + Zone::Battlefield, + ); + let mut events = Vec::new(); + + apply_counter_addition( + &mut state, + PlayerId(0), + obj_id, + CounterType::Plus1Plus1, + 0, + &mut events, + ); + apply_counter_removal(&mut state, obj_id, CounterType::Plus1Plus1, 0, &mut events); + + assert!(state.objects[&obj_id].counters.is_empty()); + assert!(events.is_empty()); + assert!(state.resolved_rules_journal.entries().is_empty()); + } + #[test] fn parameterized_power_toughness_counter_add_and_remove_marks_layers_dirty() { let mut state = GameState::new_two_player(42); diff --git a/crates/engine/src/game/engine.rs b/crates/engine/src/game/engine.rs index 315389ea4a..c2a7d7cf94 100644 --- a/crates/engine/src/game/engine.rs +++ b/crates/engine/src/game/engine.rs @@ -8804,7 +8804,12 @@ fn record_graveyard_play_permission( }); match frequency { Some(crate::types::statics::CastFrequency::OncePerTurn) => { - state.graveyard_cast_permissions_used.insert(source_id); + crate::game::ledger::consume_once_per_turn_permission( + state, + source_id, + crate::types::resolved_commands::ResolvedOncePerTurnPermission::GraveyardCast, + ) + .expect("graveyard play permission must have an unused ledger slot"); } Some(crate::types::statics::CastFrequency::OncePerTurnPerPermanentType) => { // CR 110.4: Use the player-chosen slot if one was stashed by the @@ -8819,9 +8824,14 @@ fn record_graveyard_play_permission( super::casting::pick_per_permanent_type_slot(state, source_id, played_object) }); if let Some(slot) = slot { - state - .graveyard_cast_permissions_used_per_type - .insert((source_id, slot)); + crate::game::ledger::consume_once_per_turn_permission( + state, + source_id, + crate::types::resolved_commands::ResolvedOncePerTurnPermission::GraveyardCastPermanentType { + permanent_type: slot, + }, + ) + .expect("graveyard permanent-type play slot must be unused"); } } Some(crate::types::statics::CastFrequency::Unlimited) | None => { @@ -8834,7 +8844,12 @@ fn record_exile_play_permission(state: &mut GameState, source: Option) let Some(source_id) = source else { return; }; - state.exile_play_permissions_used.insert(source_id); + crate::game::ledger::consume_once_per_turn_permission( + state, + source_id, + crate::types::resolved_commands::ResolvedOncePerTurnPermission::ExilePlay, + ) + .expect("exile play permission must have an unused ledger slot"); } /// CR 305.1 + CR 116.2a + CR 401.5: Consume the per-turn slot when a @@ -8853,7 +8868,12 @@ fn record_top_of_library_land_permission( frequency: crate::types::statics::CastFrequency, ) { if matches!(frequency, crate::types::statics::CastFrequency::OncePerTurn) { - state.top_of_library_cast_permissions_used.insert(src_id); + crate::game::ledger::consume_once_per_turn_permission( + state, + src_id, + crate::types::resolved_commands::ResolvedOncePerTurnPermission::TopOfLibraryCast, + ) + .expect("top-of-library play permission must have an unused ledger slot"); } } diff --git a/crates/engine/src/game/ledger.rs b/crates/engine/src/game/ledger.rs new file mode 100644 index 0000000000..a6a9198c9c --- /dev/null +++ b/crates/engine/src/game/ledger.rs @@ -0,0 +1,298 @@ +//! Final authority for composable per-event ledger facts. + +use crate::types::ability::TriggerDefinitionRef; +use crate::types::game_state::{GameState, SpellCastRecord}; +use crate::types::identifiers::ObjectId; +use crate::types::player::PlayerId; +use crate::types::resolved_commands::{ + ResolvedLedgerEdit, ResolvedLedgerEditCommand, ResolvedLedgerEditReplayInvariantError, + ResolvedOncePerTurnPermission, ResolvedTriggerLedgerEdit, +}; + +/// Constructs, applies, and journals one exact semantic ledger edit. +/// +/// The caller has already resolved the event's identity and any dynamic facts. +/// This boundary never re-runs casting, activation, trigger collection, or +/// permission selection. +pub fn resolve_and_apply_ledger_edit( + state: &mut GameState, + edit: ResolvedLedgerEdit, +) -> Result<(), ResolvedLedgerEditReplayInvariantError> { + let command = ResolvedLedgerEditCommand { + edit, + cause: state.current_or_begin_rules_execution_node(), + }; + apply_resolved_ledger_edit(state, &command)?; + state + .resolved_rules_journal + .record_ledger_edit(command) + .expect("resolved ledger edit must have a live journal cause"); + Ok(()) +} + +/// CR 601.2i: Append one finalized spell-cast fact without replacing another +/// player's history or an independent cast record. +pub fn record_spell_cast( + state: &mut GameState, + player: PlayerId, + record: SpellCastRecord, +) -> Result<(), ResolvedLedgerEditReplayInvariantError> { + let expected_turn_history_len = history_len( + state + .spells_cast_this_turn_by_player + .get(&player) + .map_or(0, |history| history.len()), + )?; + let expected_game_history_len = history_len( + state + .spells_cast_this_game_by_player + .get(&player) + .map_or(0, |history| history.len()), + )?; + resolve_and_apply_ledger_edit( + state, + ResolvedLedgerEdit::SpellCast { + player, + record, + expected_turn_count: state.spells_cast_this_turn, + expected_game_count: state + .spells_cast_this_game + .get(&player) + .copied() + .unwrap_or(0), + expected_turn_history_len, + expected_game_history_len, + }, + ) +} + +/// CR 602.5b: Increment exactly one activated-ability occurrence's turn and +/// game counters. +pub fn record_ability_activation( + state: &mut GameState, + source: ObjectId, + ability_index: usize, +) -> Result<(), ResolvedLedgerEditReplayInvariantError> { + let key = (source, ability_index); + resolve_and_apply_ledger_edit( + state, + ResolvedLedgerEdit::AbilityActivated { + source, + ability_index, + expected_turn_count: state + .activated_abilities_this_turn + .get(&key) + .copied() + .unwrap_or(0), + expected_game_count: state + .activated_abilities_this_game + .get(&key) + .copied() + .unwrap_or(0), + }, + ) +} + +/// CR 603.2c: Record a fully classified constrained-trigger fact. +pub fn record_trigger_fired( + state: &mut GameState, + trigger: TriggerDefinitionRef, + edit: ResolvedTriggerLedgerEdit, +) -> Result<(), ResolvedLedgerEditReplayInvariantError> { + resolve_and_apply_ledger_edit(state, ResolvedLedgerEdit::TriggerFired { trigger, edit }) +} + +/// CR 601.2i: Consume one exact frequency-bounded permission slot. +pub fn consume_once_per_turn_permission( + state: &mut GameState, + source: ObjectId, + permission: ResolvedOncePerTurnPermission, +) -> Result<(), ResolvedLedgerEditReplayInvariantError> { + resolve_and_apply_ledger_edit( + state, + ResolvedLedgerEdit::OncePerTurnPermission { source, permission }, + ) +} + +/// Applies one exact ledger edit without an event dispatcher, replacement +/// pipeline, allocator, or dynamic permission lookup. +pub fn apply_resolved_ledger_edit( + state: &mut GameState, + command: &ResolvedLedgerEditCommand, +) -> Result<(), ResolvedLedgerEditReplayInvariantError> { + match &command.edit { + ResolvedLedgerEdit::SpellCast { + player, + record, + expected_turn_count, + expected_game_count, + expected_turn_history_len, + expected_game_history_len, + } => { + if !state + .players + .iter() + .any(|candidate| candidate.id == *player) + { + return Err(ResolvedLedgerEditReplayInvariantError::UnknownPlayer( + *player, + )); + } + let turn_history_len = history_len( + state + .spells_cast_this_turn_by_player + .get(player) + .map_or(0, |history| history.len()), + )?; + let game_history_len = history_len( + state + .spells_cast_this_game_by_player + .get(player) + .map_or(0, |history| history.len()), + )?; + if state.spells_cast_this_turn != *expected_turn_count + || state + .spells_cast_this_game + .get(player) + .copied() + .unwrap_or(0) + != *expected_game_count + || turn_history_len != *expected_turn_history_len + || game_history_len != *expected_game_history_len + { + return Err(ResolvedLedgerEditReplayInvariantError::SpellCastPreconditionMismatch); + } + let next_turn_count = expected_turn_count.saturating_add(1); + let next_game_count = expected_game_count + .checked_add(1) + .ok_or(ResolvedLedgerEditReplayInvariantError::CounterOverflow)?; + state.spells_cast_this_turn = next_turn_count; + state.spells_cast_this_game.insert(*player, next_game_count); + state + .spells_cast_this_turn_by_player + .entry(*player) + .or_default() + .push_back(record.clone()); + state + .spells_cast_this_game_by_player + .entry(*player) + .or_default() + .push_back(record.clone()); + } + ResolvedLedgerEdit::AbilityActivated { + source, + ability_index, + expected_turn_count, + expected_game_count, + } => { + let key = (*source, *ability_index); + if state + .activated_abilities_this_turn + .get(&key) + .copied() + .unwrap_or(0) + != *expected_turn_count + || state + .activated_abilities_this_game + .get(&key) + .copied() + .unwrap_or(0) + != *expected_game_count + { + return Err( + ResolvedLedgerEditReplayInvariantError::AbilityActivationPreconditionMismatch, + ); + } + let next_turn_count = expected_turn_count + .checked_add(1) + .ok_or(ResolvedLedgerEditReplayInvariantError::CounterOverflow)?; + let next_game_count = expected_game_count + .checked_add(1) + .ok_or(ResolvedLedgerEditReplayInvariantError::CounterOverflow)?; + state + .activated_abilities_this_turn + .insert(key, next_turn_count); + state + .activated_abilities_this_game + .insert(key, next_game_count); + } + ResolvedLedgerEdit::TriggerFired { trigger, edit } => match edit { + ResolvedTriggerLedgerEdit::OncePerTurn => { + if !state.triggers_fired_this_turn.insert(trigger.clone()) { + return Err(ResolvedLedgerEditReplayInvariantError::TriggerAlreadyRecorded); + } + } + ResolvedTriggerLedgerEdit::OncePerGame => { + if !state.triggers_fired_this_game.insert(trigger.clone()) { + return Err(ResolvedLedgerEditReplayInvariantError::TriggerAlreadyRecorded); + } + } + ResolvedTriggerLedgerEdit::OncePerOpponentPerTurn { opponent } => { + if !state + .triggers_fired_this_turn_per_opponent + .insert((trigger.clone(), *opponent)) + { + return Err(ResolvedLedgerEditReplayInvariantError::TriggerAlreadyRecorded); + } + } + ResolvedTriggerLedgerEdit::MaxTimesPerTurn { expected_old } => { + let found = state + .trigger_fire_counts_this_turn + .get(trigger) + .copied() + .unwrap_or(0); + if found != *expected_old { + return Err( + ResolvedLedgerEditReplayInvariantError::TriggerCountPreconditionMismatch { + expected: *expected_old, + found, + }, + ); + } + let next = expected_old + .checked_add(1) + .ok_or(ResolvedLedgerEditReplayInvariantError::CounterOverflow)?; + state + .trigger_fire_counts_this_turn + .insert(trigger.clone(), next); + } + }, + ResolvedLedgerEdit::OncePerTurnPermission { source, permission } => { + let inserted = match permission { + ResolvedOncePerTurnPermission::GraveyardCast => { + state.graveyard_cast_permissions_used.insert(*source) + } + ResolvedOncePerTurnPermission::GraveyardCastPermanentType { permanent_type } => { + state + .graveyard_cast_permissions_used_per_type + .insert((*source, *permanent_type)) + } + ResolvedOncePerTurnPermission::HandCastFree => { + state.hand_cast_free_permissions_used.insert(*source) + } + ResolvedOncePerTurnPermission::AlternativeCostGrant => { + state.alt_cost_grant_permissions_used.insert(*source) + } + ResolvedOncePerTurnPermission::ExilePlay => { + state.exile_play_permissions_used.insert(*source) + } + ResolvedOncePerTurnPermission::ExileCast => { + state.exile_cast_permissions_used.insert(*source) + } + ResolvedOncePerTurnPermission::TopOfLibraryCast => { + state.top_of_library_cast_permissions_used.insert(*source) + } + }; + if !inserted { + return Err( + ResolvedLedgerEditReplayInvariantError::PermissionAlreadyConsumed(*permission), + ); + } + } + } + Ok(()) +} + +fn history_len(len: usize) -> Result { + u32::try_from(len).map_err(|_| ResolvedLedgerEditReplayInvariantError::CounterOverflow) +} diff --git a/crates/engine/src/game/mod.rs b/crates/engine/src/game/mod.rs index e8e8c59124..0a72294fd3 100644 --- a/crates/engine/src/game/mod.rs +++ b/crates/engine/src/game/mod.rs @@ -73,6 +73,7 @@ pub mod interaction; mod haunt_tests; pub mod keywords; pub mod layers; +pub mod ledger; pub mod life_costs; pub mod log; pub mod mana_abilities; diff --git a/crates/engine/src/game/restrictions.rs b/crates/engine/src/game/restrictions.rs index a88d0bcdea..488291e6f6 100644 --- a/crates/engine/src/game/restrictions.rs +++ b/crates/engine/src/game/restrictions.rs @@ -261,23 +261,10 @@ pub fn record_spell_cast_from_zone( from_zone: Zone, cast_variant: crate::types::game_state::CastingVariant, ) { - state.spells_cast_this_turn = state.spells_cast_this_turn.saturating_add(1); - *state.spells_cast_this_game.entry(player).or_insert(0) += 1; // CR 117.1: Record spell characteristics for general-purpose filtered counting. let record = spell_cast_record_for(obj, from_zone, cast_variant, false); - state - .spells_cast_this_turn_by_player - .entry(player) - .or_default() - .push_back(record.clone()); - // CR 117.1: Game-scope history mirror — not cleared between turns so - // "named {LITERAL} this game" conditions (Approach of the Second Sun) - // can see all prior casts. - state - .spells_cast_this_game_by_player - .entry(player) - .or_default() - .push_back(record); + crate::game::ledger::record_spell_cast(state, player, record) + .expect("finalized spell cast must have a valid ledger prefix"); } /// CR 702.185c: True when any player cast a spell using `variant` this turn. @@ -856,9 +843,8 @@ pub fn record_ability_activation( source_id: ObjectId, ability_index: usize, ) { - let key = (source_id, ability_index); - *state.activated_abilities_this_turn.entry(key).or_insert(0) += 1; - *state.activated_abilities_this_game.entry(key).or_insert(0) += 1; + crate::game::ledger::record_ability_activation(state, source_id, ability_index) + .expect("activated ability must have a valid ledger prefix"); } /// CR 702.142b: Compute the effective per-turn activation limit for an ability. diff --git a/crates/engine/src/game/triggers.rs b/crates/engine/src/game/triggers.rs index 1d1e06e55c..6bd0e58d59 100644 --- a/crates/engine/src/game/triggers.rs +++ b/crates/engine/src/game/triggers.rs @@ -9443,10 +9443,20 @@ fn record_trigger_fired_with_ref( match constraint { TriggerConstraint::OncePerTurn => { - state.triggers_fired_this_turn.insert(key.clone()); + crate::game::ledger::record_trigger_fired( + state, + key.clone(), + crate::types::resolved_commands::ResolvedTriggerLedgerEdit::OncePerTurn, + ) + .expect("once-per-turn trigger must have a valid ledger prefix"); } TriggerConstraint::OncePerGame => { - state.triggers_fired_this_game.insert(key.clone()); + crate::game::ledger::record_trigger_fired( + state, + key.clone(), + crate::types::resolved_commands::ResolvedTriggerLedgerEdit::OncePerGame, + ) + .expect("once-per-game trigger must have a valid ledger prefix"); } TriggerConstraint::OncePerOpponentPerTurn => { // CR 603.2: The trigger event only matches the first life-loss event @@ -9463,10 +9473,14 @@ fn record_trigger_fired_with_ref( if opponent_id == controller || state.active_player != opponent_id { return; } - let per_opponent_key = (key.clone(), opponent_id); - state - .triggers_fired_this_turn_per_opponent - .insert(per_opponent_key); + crate::game::ledger::record_trigger_fired( + state, + key.clone(), + crate::types::resolved_commands::ResolvedTriggerLedgerEdit::OncePerOpponentPerTurn { + opponent: opponent_id, + }, + ) + .expect("per-opponent trigger must have a valid ledger prefix"); } TriggerConstraint::OnlyDuringYourTurn | TriggerConstraint::OnlyDuringOpponentsTurn @@ -9477,12 +9491,21 @@ fn record_trigger_fired_with_ref( | TriggerConstraint::AtClassLevel { .. } => { // No tracking needed — checked at fire time via game/object/event state } - // CR 603.4: Increment fire count for MaxTimesPerTurn tracking. + // Increment the captured fire count for MaxTimesPerTurn tracking. TriggerConstraint::MaxTimesPerTurn { .. } => { - *state + let expected_old = state .trigger_fire_counts_this_turn - .entry(key.clone()) - .or_insert(0) += 1; + .get(key) + .copied() + .unwrap_or(0); + crate::game::ledger::record_trigger_fired( + state, + key.clone(), + crate::types::resolved_commands::ResolvedTriggerLedgerEdit::MaxTimesPerTurn { + expected_old, + }, + ) + .expect("max-times trigger must have a valid ledger prefix"); } } } diff --git a/crates/engine/src/types/mod.rs b/crates/engine/src/types/mod.rs index 8170a9088e..73aff32b5a 100644 --- a/crates/engine/src/types/mod.rs +++ b/crates/engine/src/types/mod.rs @@ -81,12 +81,15 @@ pub use resolution::{ }; pub use resolved_commands::{ ManaPaymentRecipient, ProducedManaUnit, ResolvedCommandJournalEntry, ResolvedCommandOrdinal, + ResolvedLedgerEdit, ResolvedLedgerEditCommand, ResolvedLedgerEditReplayInvariantError, ResolvedManaInsertCommand, ResolvedManaReplayInvariantError, ResolvedManaSpendCommand, - ResolvedManaSpentUnit, ResolvedObjectStatus, ResolvedObjectStatusCommand, - ResolvedObjectStatusReplayInvariantError, ResolvedPlayerEdit, ResolvedPlayerEditCommand, - ResolvedPlayerEditReplayInvariantError, ResolvedRulesCommand, ResolvedRulesJournal, - ResolvedRulesJournalError, RulesExecutionNodeKind, RulesExecutionNodeRef, SettlementNode, - SettlementNodeOrdinal, SpentManaUnit, + ResolvedManaSpentUnit, ResolvedObjectCounterCommand, ResolvedObjectCounterEdit, + ResolvedObjectCounterReplayInvariantError, ResolvedObjectStatus, ResolvedObjectStatusCommand, + ResolvedObjectStatusReplayInvariantError, ResolvedOncePerTurnPermission, ResolvedPlayerEdit, + ResolvedPlayerEditCommand, ResolvedPlayerEditReplayInvariantError, ResolvedRulesCommand, + ResolvedRulesJournal, ResolvedRulesJournalError, ResolvedTriggerLedgerEdit, + RulesExecutionNodeKind, RulesExecutionNodeRef, SettlementNode, SettlementNodeOrdinal, + SpentManaUnit, }; pub use statics::StaticMode; pub use stickers::{AppliedSticker, StickerKind, StickerLocator}; diff --git a/crates/engine/src/types/resolved_commands.rs b/crates/engine/src/types/resolved_commands.rs index 6451bffee8..dc037534db 100644 --- a/crates/engine/src/types/resolved_commands.rs +++ b/crates/engine/src/types/resolved_commands.rs @@ -8,7 +8,10 @@ use std::collections::HashSet; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use super::ability::TriggerDefinitionRef; -use super::identifiers::ObjectIncarnationRef; +use super::card_type::CoreType; +use super::counter::CounterType; +use super::game_state::SpellCastRecord; +use super::identifiers::{ObjectIncarnationRef, LEGACY_INCARNATION}; use super::mana::{ManaPipId, ManaUnit}; use super::player::{PlayerCounterKind, PlayerId}; @@ -114,6 +117,95 @@ pub struct ResolvedObjectStatusCommand { pub cause: RulesExecutionNodeRef, } +/// The final mutation to one exact object's counter map. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum ResolvedObjectCounterEdit { + /// CR 122.1 + CR 122.6: Put this final post-replacement count of counters + /// on the exact object. The actor is retained for counter-history facts. + Add { actor: PlayerId, count: u32 }, + /// CR 122.1: Remove this final already-clamped count from the exact object. + Remove { count: u32 }, +} + +/// One exact object-counter delivery after all replacement effects have settled. +/// +/// `expected_old` makes this semantic delta non-idempotent: retained-prefix +/// replay applies it exactly once instead of adding/removing another count. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResolvedObjectCounterCommand { + pub object: ObjectIncarnationRef, + pub counter_type: CounterType, + pub expected_old: u32, + pub edit: ResolvedObjectCounterEdit, + pub cause: RulesExecutionNodeRef, +} + +/// One exact constrained-trigger ledger fact. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum ResolvedTriggerLedgerEdit { + /// CR 603.2c: This trigger occurrence has used its one-per-turn fact. + OncePerTurn, + /// CR 603.2c: This trigger occurrence has used its one-per-game fact. + OncePerGame, + /// CR 603.2c: This trigger occurrence has used this opponent's per-turn fact. + OncePerOpponentPerTurn { opponent: PlayerId }, + /// Increment from the captured prior count for MaxTimesPerTurn. + MaxTimesPerTurn { expected_old: u32 }, +} + +/// A named once-per-turn permission slot consumed by a completed play or cast. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum ResolvedOncePerTurnPermission { + GraveyardCast, + GraveyardCastPermanentType { permanent_type: CoreType }, + HandCastFree, + AlternativeCostGrant, + ExilePlay, + ExileCast, + TopOfLibraryCast, +} + +/// A composable per-event ledger mutation. +/// +/// Each payload changes only one exact key or append position. Turn-boundary +/// bulk clears intentionally belong to the future turn-transition family. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum ResolvedLedgerEdit { + /// CR 601.2i: Append one finalized spell-cast fact to this player's history. + SpellCast { + player: PlayerId, + record: SpellCastRecord, + expected_turn_count: u8, + expected_game_count: u32, + expected_turn_history_len: u32, + expected_game_history_len: u32, + }, + /// CR 602.5b: Increment exactly one activated-ability occurrence's facts. + AbilityActivated { + source: super::identifiers::ObjectId, + ability_index: usize, + expected_turn_count: u32, + expected_game_count: u32, + }, + /// CR 603.2c: Record one constrained trigger occurrence. + TriggerFired { + trigger: TriggerDefinitionRef, + edit: ResolvedTriggerLedgerEdit, + }, + /// CR 601.2i: Consume one already-selected bounded permission slot. + OncePerTurnPermission { + source: super::identifiers::ObjectId, + permission: ResolvedOncePerTurnPermission, + }, +} + +/// One exact per-event ledger mutation with its causal node. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResolvedLedgerEditCommand { + pub edit: ResolvedLedgerEdit, + pub cause: RulesExecutionNodeRef, +} + /// Semantic command payload currently carried by a resolved-rules journal entry. /// /// Additional command families are intentionally added by their owning P2 @@ -124,6 +216,8 @@ pub enum ResolvedRulesCommand { ManaSpend(ResolvedManaSpendCommand), PlayerEdit(ResolvedPlayerEditCommand), ObjectStatus(ResolvedObjectStatusCommand), + ObjectCounter(ResolvedObjectCounterCommand), + LedgerEdit(ResolvedLedgerEditCommand), } /// Typed failure while applying an already-resolved mana command to a replay state. @@ -245,6 +339,106 @@ impl std::fmt::Display for ResolvedObjectStatusReplayInvariantError { impl std::error::Error for ResolvedObjectStatusReplayInvariantError {} +/// Typed failure while applying an already-resolved object-counter command. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResolvedObjectCounterReplayInvariantError { + MissingObject(ObjectIncarnationRef), + StaleObject { + expected: ObjectIncarnationRef, + found: ObjectIncarnationRef, + }, + ZeroCount, + CounterPreconditionMismatch { + counter_type: CounterType, + expected: u32, + found: u32, + }, + CounterOverflow { + counter_type: CounterType, + previous: u32, + added: u32, + }, +} + +impl std::fmt::Display for ResolvedObjectCounterReplayInvariantError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MissingObject(object) => { + write!(f, "resolved counter command cannot find {object:?}") + } + Self::StaleObject { expected, found } => write!( + f, + "resolved counter command expected {expected:?}, found {found:?}" + ), + Self::ZeroCount => write!(f, "resolved counter command has a zero count"), + Self::CounterPreconditionMismatch { + counter_type, + expected, + found, + } => write!( + f, + "resolved {counter_type:?} counter command expected {expected}, found {found}" + ), + Self::CounterOverflow { + counter_type, + previous, + added, + } => write!( + f, + "resolved {counter_type:?} counter command overflows {previous} + {added}" + ), + } + } +} + +impl std::error::Error for ResolvedObjectCounterReplayInvariantError {} + +/// Typed failure while applying an already-resolved per-event ledger command. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResolvedLedgerEditReplayInvariantError { + UnknownPlayer(PlayerId), + SpellCastPreconditionMismatch, + AbilityActivationPreconditionMismatch, + TriggerAlreadyRecorded, + TriggerCountPreconditionMismatch { expected: u32, found: u32 }, + PermissionAlreadyConsumed(ResolvedOncePerTurnPermission), + CounterOverflow, +} + +impl std::fmt::Display for ResolvedLedgerEditReplayInvariantError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::UnknownPlayer(player) => write!(f, "unknown ledger-command player {}", player.0), + Self::SpellCastPreconditionMismatch => { + write!( + f, + "resolved spell-cast command does not match its ledger prefix" + ) + } + Self::AbilityActivationPreconditionMismatch => write!( + f, + "resolved activated-ability command does not match its ledger prefix" + ), + Self::TriggerAlreadyRecorded => { + write!( + f, + "resolved trigger command repeats an existing once-only fact" + ) + } + Self::TriggerCountPreconditionMismatch { expected, found } => write!( + f, + "resolved trigger command expected count {expected}, found {found}" + ), + Self::PermissionAlreadyConsumed(permission) => { + write!(f, "resolved {permission:?} permission was already consumed") + } + Self::CounterOverflow => write!(f, "resolved ledger command overflows a counter"), + } + } +} + +impl std::error::Error for ResolvedLedgerEditReplayInvariantError {} + /// Semantic category of a resolved rules-execution node. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum RulesExecutionNodeKind { @@ -672,6 +866,22 @@ impl ResolvedRulesJournal { self.append_command(command.cause, ResolvedRulesCommand::ObjectStatus(command)) } + /// Records one final object-counter delivery under its causal node. + pub fn record_object_counter( + &mut self, + command: ResolvedObjectCounterCommand, + ) -> Result { + self.append_command(command.cause, ResolvedRulesCommand::ObjectCounter(command)) + } + + /// Records one exact semantic ledger mutation under its causal node. + pub fn record_ledger_edit( + &mut self, + command: ResolvedLedgerEditCommand, + ) -> Result { + self.append_command(command.cause, ResolvedRulesCommand::LedgerEdit(command)) + } + fn begin_settlement( &mut self, identity_for: impl FnOnce(SettlementNodeOrdinal) -> RulesExecutionNodeRef, @@ -867,7 +1077,10 @@ impl ResolvedRulesJournal { } } } - ResolvedRulesCommand::PlayerEdit(_) | ResolvedRulesCommand::ObjectStatus(_) => {} + ResolvedRulesCommand::PlayerEdit(_) + | ResolvedRulesCommand::ObjectStatus(_) + | ResolvedRulesCommand::ObjectCounter(_) + | ResolvedRulesCommand::LedgerEdit(_) => {} } } for node in &self.nodes { @@ -1052,6 +1265,36 @@ impl ResolvedRulesJournal { )); } } + ResolvedRulesCommand::ObjectCounter(command) => { + if entry.node != command.cause || object_counter_edit_is_empty(&command.edit) { + return Err(ResolvedRulesJournalError::InvalidSerializedAuthority( + "object-counter command has an empty edit or unrelated cause".to_string(), + )); + } + if command.object.incarnation == LEGACY_INCARNATION { + return Err(ResolvedRulesJournalError::InvalidSerializedAuthority( + "object-counter command cannot use a legacy object identity".to_string(), + )); + } + if let ResolvedObjectCounterEdit::Remove { count } = &command.edit { + if *count > command.expected_old { + return Err(ResolvedRulesJournalError::InvalidSerializedAuthority( + "object-counter removal has an impossible predecessor".to_string(), + )); + } + } + } + ResolvedRulesCommand::LedgerEdit(command) => { + if entry.node != command.cause + || ledger_edit_is_invalid(&command.edit) + || ledger_edit_has_legacy_object_identity(&command.edit) + { + return Err(ResolvedRulesJournalError::InvalidSerializedAuthority( + "ledger command has an impossible edit, legacy identity, or unrelated cause" + .to_string(), + )); + } + } } Ok(()) } @@ -1097,9 +1340,55 @@ fn player_edit_is_empty(edit: &ResolvedPlayerEdit) -> bool { } } +fn object_counter_edit_is_empty(edit: &ResolvedObjectCounterEdit) -> bool { + match edit { + ResolvedObjectCounterEdit::Add { count, .. } + | ResolvedObjectCounterEdit::Remove { count } => *count == 0, + } +} + +fn ledger_edit_is_invalid(edit: &ResolvedLedgerEdit) -> bool { + match edit { + ResolvedLedgerEdit::SpellCast { + expected_game_count, + expected_turn_history_len, + expected_game_history_len, + .. + } => { + // `expected_turn_count` is a u8 advanced via saturating_add in the + // applier, so 255 is a legitimate saturated value, not a reserved + // sentinel — only the u32 count fields carry the u32::MAX + // "never recorded" marker this pre-screen fails closed on. + *expected_game_count == u32::MAX + || *expected_turn_history_len == u32::MAX + || *expected_game_history_len == u32::MAX + } + ResolvedLedgerEdit::AbilityActivated { + expected_turn_count, + expected_game_count, + .. + } => *expected_turn_count == u32::MAX || *expected_game_count == u32::MAX, + ResolvedLedgerEdit::TriggerFired { + edit: ResolvedTriggerLedgerEdit::MaxTimesPerTurn { expected_old }, + .. + } => *expected_old == u32::MAX, + ResolvedLedgerEdit::TriggerFired { .. } + | ResolvedLedgerEdit::OncePerTurnPermission { .. } => false, + } +} + +fn ledger_edit_has_legacy_object_identity(edit: &ResolvedLedgerEdit) -> bool { + matches!( + edit, + ResolvedLedgerEdit::TriggerFired { trigger, .. } + if trigger.source.incarnation == LEGACY_INCARNATION + ) +} + #[cfg(test)] mod tests { use super::*; + use crate::types::ability::{TriggerBaseSetInstanceRef, TriggerDefinitionOccurrenceRef}; use crate::types::identifiers::ObjectId; use crate::types::mana::{ManaRestriction, ManaType}; @@ -1355,4 +1644,97 @@ mod tests { ) .is_err()); } + + #[test] + fn counter_and_ledger_commands_roundtrip_and_reject_malformed_payloads() { + let mut journal = ResolvedRulesJournal::default(); + let cause = journal.begin_proposal().unwrap(); + journal + .record_object_counter(ResolvedObjectCounterCommand { + object: ObjectIncarnationRef::of(ObjectId(9), 0), + counter_type: CounterType::Plus1Plus1, + expected_old: 2, + edit: ResolvedObjectCounterEdit::Add { + actor: PlayerId(0), + count: 1, + }, + cause, + }) + .unwrap(); + journal + .record_ledger_edit(ResolvedLedgerEditCommand { + edit: ResolvedLedgerEdit::AbilityActivated { + source: ObjectId(9), + ability_index: 0, + expected_turn_count: 0, + expected_game_count: 0, + }, + cause, + }) + .unwrap(); + journal + .record_ledger_edit(ResolvedLedgerEditCommand { + edit: ResolvedLedgerEdit::TriggerFired { + trigger: TriggerDefinitionRef { + source: ObjectIncarnationRef::of(ObjectId(10), 0), + occurrence: TriggerDefinitionOccurrenceRef::Printed { + base_set: TriggerBaseSetInstanceRef::INITIAL, + printed_index: 0, + }, + }, + edit: ResolvedTriggerLedgerEdit::OncePerTurn, + }, + cause, + }) + .unwrap(); + assert_eq!( + serde_json::from_value::(serde_json::to_value(&journal).unwrap()) + .unwrap(), + journal + ); + + let mut empty_counter = journal.clone(); + let Some(ResolvedRulesCommand::ObjectCounter(command)) = + empty_counter.entries[1].command.as_mut() + else { + panic!("entry 1 must be the counter command"); + }; + command.edit = ResolvedObjectCounterEdit::Add { + actor: PlayerId(0), + count: 0, + }; + assert!(serde_json::from_value::( + serde_json::to_value(empty_counter).unwrap() + ) + .is_err()); + + // A pre-incarnation bare object id deserializes to LEGACY_INCARNATION. + // It is valid only for its original compatibility readers, never for a + // new executable command whose applier requires an exact occurrence. + let mut legacy_counter = serde_json::to_value(&journal).unwrap(); + legacy_counter["entries"][1]["command"]["ObjectCounter"]["object"] = serde_json::json!(9); + assert!(serde_json::from_value::(legacy_counter).is_err()); + + let mut legacy_trigger = serde_json::to_value(&journal).unwrap(); + legacy_trigger["entries"][3]["command"]["LedgerEdit"]["edit"]["TriggerFired"]["trigger"] + ["source"] = serde_json::json!(10); + assert!(serde_json::from_value::(legacy_trigger).is_err()); + + let mut impossible_ledger = journal.clone(); + let Some(ResolvedRulesCommand::LedgerEdit(command)) = + impossible_ledger.entries[2].command.as_mut() + else { + panic!("entry 2 must be the ledger command"); + }; + command.edit = ResolvedLedgerEdit::AbilityActivated { + source: ObjectId(9), + ability_index: 0, + expected_turn_count: u32::MAX, + expected_game_count: 0, + }; + assert!(serde_json::from_value::( + serde_json::to_value(impossible_ledger).unwrap() + ) + .is_err()); + } } diff --git a/crates/engine/tests/integration/cr733_resolved_commands_p2.rs b/crates/engine/tests/integration/cr733_resolved_commands_p2.rs index a533abb22e..934ad9050b 100644 --- a/crates/engine/tests/integration/cr733_resolved_commands_p2.rs +++ b/crates/engine/tests/integration/cr733_resolved_commands_p2.rs @@ -1,20 +1,24 @@ -//! P2 replay coverage for resolved mana, scalar, and object-status commands. +//! P2 replay coverage for resolved mana, scalar, status, counter, and ledger commands. use engine::game::scenario::{GameRunner, GameScenario, P0, P1}; use engine::types::actions::GameAction; use engine::types::card_type::CoreType; +use engine::types::counter::CounterType; use engine::types::game_state::GameState; use engine::types::identifiers::ObjectId; use engine::types::mana::ManaColor; use engine::types::phase::Phase; use engine::types::player::PlayerCounterKind; use engine::types::resolved_commands::{ - ResolvedManaReplayInvariantError, ResolvedObjectStatusReplayInvariantError, ResolvedPlayerEdit, - ResolvedPlayerEditCommand, ResolvedPlayerEditReplayInvariantError, ResolvedRulesCommand, - RulesExecutionNodeRef, + ResolvedLedgerEdit, ResolvedLedgerEditReplayInvariantError, ResolvedManaReplayInvariantError, + ResolvedObjectCounterReplayInvariantError, ResolvedObjectStatusReplayInvariantError, + ResolvedPlayerEdit, ResolvedPlayerEditCommand, ResolvedPlayerEditReplayInvariantError, + ResolvedRulesCommand, RulesExecutionNodeRef, }; const DIMIR_SIGNET_ORACLE: &str = "{1}, {T}: Add {U}{B}."; +const STONY_STRENGTH_ORACLE: &str = + "Put a +1/+1 counter on target creature you control. Untap that creature."; fn make_artifact(runner: &mut GameRunner, id: ObjectId) { let object = runner.state_mut().objects.get_mut(&id).unwrap(); @@ -69,6 +73,12 @@ fn apply_semantic_command(state: &mut GameState, command: &ResolvedRulesCommand) ResolvedRulesCommand::ObjectStatus(command) => { engine::game::object_state::apply_resolved_object_edit(state, command).unwrap(); } + ResolvedRulesCommand::ObjectCounter(command) => { + engine::game::effects::counters::apply_resolved_counter_edit(state, command).unwrap(); + } + ResolvedRulesCommand::LedgerEdit(command) => { + engine::game::ledger::apply_resolved_ledger_edit(state, command).unwrap(); + } } } @@ -151,9 +161,10 @@ fn exact_mana_spend_rejects_a_second_removal() { observed_spend = true; break; } - ResolvedRulesCommand::PlayerEdit(_) | ResolvedRulesCommand::ObjectStatus(_) => { - apply_semantic_command(&mut replay, command); - } + ResolvedRulesCommand::PlayerEdit(_) + | ResolvedRulesCommand::ObjectStatus(_) + | ResolvedRulesCommand::ObjectCounter(_) + | ResolvedRulesCommand::LedgerEdit(_) => apply_semantic_command(&mut replay, command), } } assert!( @@ -320,3 +331,115 @@ fn scalar_commands_compose_across_life_energy_counters_and_speed() { "each final scalar edit has one journal command" ); } + +fn counter_spell_states() -> (GameState, GameState, ObjectId) { + let mut scenario = GameScenario::new_n_player(2, 7); + scenario.at_phase(Phase::PreCombatMain); + let target = scenario.add_creature(P0, "Counter Target", 2, 2).id(); + let spell = scenario + .add_spell_to_hand_from_oracle(P0, "Stony Strength", false, STONY_STRENGTH_ORACLE) + .id(); + let mut runner = scenario.build(); + let pre_state = runner.state().clone(); + + runner.cast(spell).target_object(target).resolve(); + + (pre_state, runner.state().clone(), target) +} + +/// A real counter spell records the final object-counter delivery. Replaying +/// the semantic journal never consults the replacement pipeline a second time. +#[test] +fn real_counter_spell_replays_recorded_object_counter_delivery() { + let (pre_state, ordinary_state, target) = counter_spell_states(); + let commands = semantic_commands(&ordinary_state); + assert!(commands.iter().any(|command| matches!( + command, + ResolvedRulesCommand::ObjectCounter(command) + if command.object.object_id == target + && command.counter_type == CounterType::Plus1Plus1 + ))); + + let mut replay = pre_state; + replay.resolved_rules_journal = ordinary_state.resolved_rules_journal.clone(); + for command in &commands { + apply_semantic_command(&mut replay, command); + } + + assert_eq!( + replay.objects[&target].counters, ordinary_state.objects[&target].counters, + "replay preserves the final post-replacement counter count" + ); + assert_eq!( + replay.counter_added_this_turn, ordinary_state.counter_added_this_turn, + "counter history is part of the semantic counter delivery" + ); +} + +/// Counter deliveries are exact occurrence transitions: a duplicate does not +/// add more counters, and an object with the same storage id but a new +/// incarnation is rejected. +#[test] +fn recorded_counter_rejects_double_apply_and_stale_incarnation() { + let (pre_state, ordinary_state, target) = counter_spell_states(); + let command = semantic_commands(&ordinary_state) + .into_iter() + .find_map(|command| match command { + ResolvedRulesCommand::ObjectCounter(command) if command.object.object_id == target => { + Some(command) + } + _ => None, + }) + .expect("Stony Strength must journal its object-counter delivery"); + + let mut replay = pre_state.clone(); + engine::game::effects::counters::apply_resolved_counter_edit(&mut replay, &command).unwrap(); + assert!(matches!( + engine::game::effects::counters::apply_resolved_counter_edit(&mut replay, &command), + Err(ResolvedObjectCounterReplayInvariantError::CounterPreconditionMismatch { .. }) + )); + + let mut stale = pre_state; + stale.objects.get_mut(&target).unwrap().bump_incarnation(); + assert!(matches!( + engine::game::effects::counters::apply_resolved_counter_edit(&mut stale, &command), + Err(ResolvedObjectCounterReplayInvariantError::StaleObject { .. }) + )); +} + +/// A finalized cast records an append-only spell history command. Applying it +/// twice fails its captured prefix rather than appending a duplicate history. +#[test] +fn real_spell_cast_replays_its_exact_ledger_record_once() { + let (pre_state, ordinary_state, _) = counter_spell_states(); + let command = semantic_commands(&ordinary_state) + .into_iter() + .find_map(|command| match command { + ResolvedRulesCommand::LedgerEdit(command) + if matches!(&command.edit, ResolvedLedgerEdit::SpellCast { .. }) => + { + Some(command) + } + _ => None, + }) + .expect("the real spell cast must journal its exact ledger record"); + + let mut replay = pre_state; + engine::game::ledger::apply_resolved_ledger_edit(&mut replay, &command).unwrap(); + assert_eq!( + replay.spells_cast_this_turn, + ordinary_state.spells_cast_this_turn + ); + assert_eq!( + replay.spells_cast_this_game, + ordinary_state.spells_cast_this_game + ); + assert_eq!( + replay.spells_cast_this_turn_by_player, + ordinary_state.spells_cast_this_turn_by_player + ); + assert!(matches!( + engine::game::ledger::apply_resolved_ledger_edit(&mut replay, &command), + Err(ResolvedLedgerEditReplayInvariantError::SpellCastPreconditionMismatch) + )); +}