Skip to content

feat(engine): journal CR 603.7 delayed-trigger and CR 611.2a continuous-effect installs - #6649

Merged
matthewevans merged 1 commit into
mainfrom
cr733/modifier-install
Jul 26, 2026
Merged

feat(engine): journal CR 603.7 delayed-trigger and CR 611.2a continuous-effect installs#6649
matthewevans merged 1 commit into
mainfrom
cr733/modifier-install

Conversation

@matthewevans

@matthewevans matthewevans commented Jul 26, 2026

Copy link
Copy Markdown
Member

Adds the modifier-installation half of the CR 733 resolved-command journal:
every delayed triggered ability and every transient continuous effect now
resolves, applies, and journals through a single authority, with a paired
replay applier that installs the exact recorded values.

Two variants, not one parameterized variant

ResolvedRulesCommand gains DelayedTriggerInstall and
ContinuousEffectInstall as siblings. CLAUDE.md's categorical-boundary rule
decides this: the parameterization axis would straddle two CR sections the
engine resolves through entirely separate machinery.

  • A delayed triggered ability is CR 603.7. It never touches the CR 613 layer
    system; it waits in delayed_triggers until its condition occurs, then
    goes on the stack as an ordinary triggered ability (CR 603.7b). It draws
    NO allocator value.
  • A transient continuous effect is CR 611.2a. It never uses the stack; it
    applies continuously through the CR 613 layers until its duration ends. It
    draws TWO allocator values -- an effect id, and a CR 613.7b timestamp that
    orders it within its layer.

Their expected_*/resulting_* shapes therefore differ in kind, not in a leaf
value: one command carries an allocator receipt to verify, the other has
nothing to verify. Collapsing them would put two unrelated invariants behind
one validator arm and one applier. This is the categorical-boundary failure
CLAUDE.md warns about, not the sibling-cluster smell it warns about -- the two
share no name root, no context label, and no comparator/scope axis.

Authorities

Delayed triggers had 11 raw delayed_triggers.push sites across 10 production
files and no authority; game::triggers::install_delayed_trigger is new and
now owns all of them. Transient continuous effects already had exactly one
production insertion authority -- GameState::add_transient_continuous_effect
-- so that half is journaling only, no extraction.

Replay installs the whole DelayedTrigger / TransientContinuousEffect
verbatim: the CR 603.7c bound ability with its targets, the CR 611.2c fixed
affected set, and both allocator draws. Nothing is re-selected, re-read, or
re-drawn. apply_resolved_continuous_effect advances next_continuous_effect_id
and next_timestamp past the installed values, mirroring how the token-birth
family advances next_object_id, so a replayed state cannot hand the same id
or timestamp out twice.

Both appliers verify their expected_installed_count against live state and
return a typed error before mutating anything; the continuous-effect applier
additionally rejects an id already live.

Scope

Installation only. Both collections also have a removal/expiry half (delayed
triggers: collect_matching_delayed_triggers remove + cleanup retain;
continuous effects: the layers.rs duration-prune family), which remains
unjournaled and is not covered here.

Summary by CodeRabbit

  • New Features

    • Added deterministic replay support for delayed triggers and temporary continuous effects.
    • Recorded game-rule installations can now be reapplied with validation to prevent mismatches, duplicates, or invalid state changes.
  • Bug Fixes

    • Improved consistency when scheduling delayed actions such as sacrifices, returns, exiles, and rebound effects.
  • Tests

    • Added integration coverage for replaying delayed triggers and temporary effects, including validation failure scenarios.

…us-effect installs

Adds the modifier-installation half of the CR 733 resolved-command journal:
every delayed triggered ability and every transient continuous effect now
resolves, applies, and journals through a single authority, with a paired
replay applier that installs the exact recorded values.

Two variants, not one parameterized variant
-------------------------------------------
`ResolvedRulesCommand` gains `DelayedTriggerInstall` and
`ContinuousEffectInstall` as siblings. CLAUDE.md's categorical-boundary rule
decides this: the parameterization axis would straddle two CR sections the
engine resolves through entirely separate machinery.

  - A delayed triggered ability is CR 603.7. It never touches the CR 613 layer
    system; it waits in `delayed_triggers` until its condition occurs, then
    goes on the stack as an ordinary triggered ability (CR 603.7b). It draws
    NO allocator value.
  - A transient continuous effect is CR 611.2a. It never uses the stack; it
    applies continuously through the CR 613 layers until its duration ends. It
    draws TWO allocator values -- an effect id, and a CR 613.7b timestamp that
    orders it within its layer.

Their `expected_*`/`resulting_*` shapes therefore differ in kind, not in a leaf
value: one command carries an allocator receipt to verify, the other has
nothing to verify. Collapsing them would put two unrelated invariants behind
one validator arm and one applier. This is the categorical-boundary failure
CLAUDE.md warns about, not the sibling-cluster smell it warns about -- the two
share no name root, no context label, and no comparator/scope axis.

Authorities
-----------
Delayed triggers had 11 raw `delayed_triggers.push` sites across 10 production
files and no authority; `game::triggers::install_delayed_trigger` is new and
now owns all of them. Transient continuous effects already had exactly one
production insertion authority -- `GameState::add_transient_continuous_effect`
-- so that half is journaling only, no extraction.

Replay installs the whole `DelayedTrigger` / `TransientContinuousEffect`
verbatim: the CR 603.7c bound ability with its targets, the CR 611.2c fixed
affected set, and both allocator draws. Nothing is re-selected, re-read, or
re-drawn. `apply_resolved_continuous_effect` advances `next_continuous_effect_id`
and `next_timestamp` past the installed values, mirroring how the token-birth
family advances `next_object_id`, so a replayed state cannot hand the same id
or timestamp out twice.

Both appliers verify their `expected_installed_count` against live state and
return a typed error before mutating anything; the continuous-effect applier
additionally rejects an id already live.

Scope
-----
Installation only. Both collections also have a removal/expiry half (delayed
triggers: `collect_matching_delayed_triggers` remove + cleanup retain;
continuous effects: the layers.rs duration-prune family), which remains
unjournaled and is not covered here.
@matthewevans
matthewevans enabled auto-merge July 26, 2026 04:50
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Delayed-trigger and transient continuous-effect installations now flow through resolved commands, journaling, and replay invariant checks. Existing delayed-trigger creation sites use the centralized installer, while integration tests cover exact replay and fail-closed mismatches.

Changes

Resolved installation authority

Layer / File(s) Summary
Resolved installation command contracts
crates/engine/src/types/resolved_commands.rs
Adds command payloads, journal variants, recording methods, and validation for delayed-trigger and transient continuous-effect installations.
Centralized delayed-trigger installation
crates/engine/src/game/triggers.rs, crates/engine/src/game/{blitz.rs,dash.rs,stack.rs}, crates/engine/src/game/effects/*
Routes delayed-trigger creation through the shared installer, which applies and journals each installation.
Continuous-effect installation authority
crates/engine/src/types/game_state.rs
Installs transient continuous effects through resolved commands with count, allocator high-water, duplicate-id, and layer invalidation checks.
Replay dispatch and integration coverage
crates/engine/tests/integration/*
Replays both command variants and verifies exact state reproduction plus rejected precondition and duplicate-install scenarios.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Resolution
  participant GameState
  participant Journal
  participant Replay
  Resolution->>GameState: Install delayed trigger or continuous effect
  GameState->>Journal: Record resolved command
  Replay->>GameState: Apply recorded command
  GameState-->>Replay: Validate preconditions and state updates
Loading

Possibly related PRs

  • phase-rs/phase#6331 — Provides the CR733 resolved-rules journal and command infrastructure extended here.
  • phase-rs/phase#6336 — Adds related resolved command families and replay application wiring.
  • phase-rs/phase#6644 — Extends the same resolved-command replay path with additional command variants.

Suggested reviewers: kiannidev, lgray

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: journaling delayed-trigger and continuous-effect installs in engine.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch cr733/modifier-install

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
crates/engine/src/types/game_state.rs (1)

16672-16682: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Consider a symmetric live-timestamp uniqueness guard.

The duplicate check covers effect.id but not effect.timestamp. Per CR 613.7 the timestamp is what orders an effect within its layer, so a diverged replay that installs an effect sharing a live timestamp yields an ambiguous layer order that no later check catches — the same class of failure the id guard fails closed on. Fails closed before mutation, same as the id check.

♻️ Symmetric timestamp guard
         if self
             .transient_continuous_effects
             .iter()
             .any(|effect| effect.id == command.effect.id)
         {
             return Err(
                 ResolvedContinuousEffectReplayInvariantError::DuplicateEffectId(command.effect.id),
             );
         }
+        // CR 613.7: the timestamp is the intra-layer ordering key, so two live
+        // effects sharing one leave the layer order undefined.
+        if self
+            .transient_continuous_effects
+            .iter()
+            .any(|effect| effect.timestamp == command.effect.timestamp)
+        {
+            return Err(
+                ResolvedContinuousEffectReplayInvariantError::DuplicateTimestamp(
+                    command.effect.timestamp,
+                ),
+            );
+        }

Requires a matching DuplicateTimestamp(u64) variant on ResolvedContinuousEffectReplayInvariantError.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/src/types/game_state.rs` around lines 16672 - 16682, Extend the
pre-mutation validation in the transient continuous-effect replay path alongside
the existing effect.id check to reject any live effect whose timestamp matches
command.effect.timestamp. Add and use the matching DuplicateTimestamp(u64)
variant on ResolvedContinuousEffectReplayInvariantError, preserving the
fail-closed behavior before state mutation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/engine/src/types/resolved_commands.rs`:
- Around line 2302-2316: The ResolvedRulesCommand::ContinuousEffectInstall
validation currently accepts any lower allocator receipt, allowing replay to
advance allocators incorrectly. Replace the upper-bound checks with strict
equality against the authoritative allocator results for this installation, and
reject allocator arithmetic overflow; retain validation that entry.node matches
command.cause and return InvalidSerializedAuthority for mismatches.
- Around line 247-299: The install-position receipts in
ResolvedDelayedTriggerCommand and the transient-effect installation flow are not
replay-stable when prior delayed triggers fire or transient effects expire.
Remove or replace these expected count precondition checks with authoritative
replay of the corresponding removal/expiry lifecycle transitions, while
preserving exact allocator advancement and installed-value behavior; do not
merely weaken the validation.

---

Nitpick comments:
In `@crates/engine/src/types/game_state.rs`:
- Around line 16672-16682: Extend the pre-mutation validation in the transient
continuous-effect replay path alongside the existing effect.id check to reject
any live effect whose timestamp matches command.effect.timestamp. Add and use
the matching DuplicateTimestamp(u64) variant on
ResolvedContinuousEffectReplayInvariantError, preserving the fail-closed
behavior before state mutation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ae0acd0d-3f6c-43af-b417-2c31167b513a

📥 Commits

Reviewing files that changed from the base of the PR and between 5a2341d and bd0e572.

📒 Files selected for processing (17)
  • crates/engine/src/game/blitz.rs
  • crates/engine/src/game/dash.rs
  • crates/engine/src/game/effects/counters.rs
  • crates/engine/src/game/effects/delayed_trigger.rs
  • crates/engine/src/game/effects/encore.rs
  • crates/engine/src/game/effects/exile_resolving_spell.rs
  • crates/engine/src/game/effects/myriad.rs
  • crates/engine/src/game/effects/rebound.rs
  • crates/engine/src/game/effects/token.rs
  • crates/engine/src/game/stack.rs
  • crates/engine/src/game/triggers.rs
  • crates/engine/src/types/game_state.rs
  • crates/engine/src/types/resolved_commands.rs
  • crates/engine/tests/integration/cr733_resolved_commands_p2.rs
  • crates/engine/tests/integration/cr733_resolved_draw.rs
  • crates/engine/tests/integration/cr733_resolved_modifier_install.rs
  • crates/engine/tests/integration/main.rs

Comment on lines +247 to +299
/// `expected_installed_count` is the length of `GameState::delayed_triggers`
/// immediately before the push. Installed triggers are consumed by
/// `check_delayed_triggers` (CR 603.7b, one firing) and pruned at cleanup, so
/// the live length at install time is a genuine function of everything the
/// replayed prefix did. Verifying it fails a replay closed the moment journal
/// order stops matching execution order. (It is a storage-position check only:
/// the rules order in which simultaneously firing triggers reach the stack is
/// chosen by their controller under CR 603.3b, not by this index.)
///
/// No allocator value is drawn: unlike a continuous effect (CR 613.7b) a
/// delayed triggered ability takes no timestamp, because it does not
/// participate in the CR 613 layer system until it actually triggers and goes
/// on the stack as an ordinary triggered ability.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ResolvedDelayedTriggerCommand {
pub trigger: DelayedTrigger,
pub expected_installed_count: usize,
pub cause: RulesExecutionNodeRef,
}

/// Typed failure while applying one already-resolved delayed-trigger install.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ResolvedDelayedTriggerReplayInvariantError {
#[error("delayed-trigger install precondition mismatch: expected {expected} already installed, found {found}")]
InstalledCountPreconditionMismatch { expected: usize, found: usize },
}

/// One exact CR 611.2a transient continuous-effect installation.
///
/// A continuous effect generated by the resolution of a spell or ability lasts
/// as long as that spell or ability stated (CR 611.2a) and, per CR 611.2c, the
/// set of objects it affects is fixed when it begins. Both of those decisions
/// are already baked into the `TransientContinuousEffect` the authority built,
/// so the effect is recorded whole rather than as a recipe to re-evaluate.
///
/// Two allocator draws live inside that value and MUST be installed rather than
/// re-drawn:
/// - `effect.timestamp`, taken from `GameState::next_timestamp` per CR 613.7b
/// ("a continuous effect generated by the resolution of a spell or ability
/// receives a timestamp at the time it's created"). Re-drawing it at replay
/// would reorder the effect against every other effect in its CR 613 layer.
/// - `effect.id`, taken from `GameState::next_continuous_effect_id`, which is
/// the handle later duration/recipient binding addresses the effect by.
///
/// Both are carried as post-draw high-water marks
/// (`resulting_next_continuous_effect_id` / `resulting_next_timestamp`) so
/// replay advances the allocators past the installed values exactly the way the
/// token-birth family advances `next_object_id`, rather than leaving a
/// replayed state that would hand the same id or timestamp out twice.
///
/// `expected_installed_count` mirrors the delayed-trigger command: the live
/// length of `GameState::transient_continuous_effects` before the push, which
/// duration expiry continuously shortens.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Journal lifecycle removals/expiries before relying on install-position receipts.

These counts cease to be replay-stable once a delayed trigger fires or a transient effect expires: replay retains the prior install, while live execution removes it. A later install recorded with count 0 then fails closed. Record the corresponding removal/expiry transitions (or replay them through an equivalent authoritative lifecycle path); do not weaken the receipt check.

Based on the PR objective that removal and expiry journaling are out of scope, this breaks the stated replay invariant.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/src/types/resolved_commands.rs` around lines 247 - 299, The
install-position receipts in ResolvedDelayedTriggerCommand and the
transient-effect installation flow are not replay-stable when prior delayed
triggers fire or transient effects expire. Remove or replace these expected
count precondition checks with authoritative replay of the corresponding
removal/expiry lifecycle transitions, while preserving exact allocator
advancement and installed-value behavior; do not merely weaken the validation.

Source: Path instructions

Comment on lines +2302 to +2316
ResolvedRulesCommand::ContinuousEffectInstall(command) => {
// CR 613.7b: the effect's timestamp was drawn when it was
// created, so it — and the effect id drawn alongside it — must
// lie strictly below the high-water the draw left behind, or the
// receipt describes an allocation that never happened.
if entry.node != command.cause
|| command.effect.id >= command.resulting_next_continuous_effect_id
|| command.effect.timestamp >= command.resulting_next_timestamp
{
return Err(ResolvedRulesJournalError::InvalidSerializedAuthority(
"continuous-effect install command has an impossible allocator receipt, \
or an unrelated cause"
.to_string(),
));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Validate exact allocator receipts, not merely upper bounds.

A forged serialized command can use effect.id = 1 with resulting_next_continuous_effect_id = 100; it passes this check, and replay advances the live allocator to 100. Subsequent effects then diverge from the original execution. Require the recorded post-draw values to equal the authoritative allocator result for this installation, including rejecting overflow.

As per path instructions, journal replay must use strict validation rather than best-effort receipts.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/src/types/resolved_commands.rs` around lines 2302 - 2316, The
ResolvedRulesCommand::ContinuousEffectInstall validation currently accepts any
lower allocator receipt, allowing replay to advance allocators incorrectly.
Replace the upper-bound checks with strict equality against the authoritative
allocator results for this installation, and reject allocator arithmetic
overflow; retain validation that entry.node matches command.cause and return
InvalidSerializedAuthority for mismatches.

Source: Path instructions

@matthewevans
matthewevans added this pull request to the merge queue Jul 26, 2026
@github-actions

Copy link
Copy Markdown

Parse changes introduced by this PR

✓ No card-parse changes detected.

Merged via the queue into main with commit ceff562 Jul 26, 2026
15 checks passed
@matthewevans
matthewevans deleted the cr733/modifier-install branch July 26, 2026 05:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant