Skip to content

ship/canonicalize delayed trigger lifecycle - #6933

Merged
matthewevans merged 62 commits into
mainfrom
ship/canonicalize-delayed-trigger-lifecycle
Aug 3, 2026
Merged

ship/canonicalize delayed trigger lifecycle#6933
matthewevans merged 62 commits into
mainfrom
ship/canonicalize-delayed-trigger-lifecycle

Conversation

@matthewevans

@matthewevans matthewevans commented Aug 2, 2026

Copy link
Copy Markdown
Member
  • feat(engine): canonicalize delayed trigger lifecycle
  • fix(engine): centralize persisted state ingress
  • fix(engine): settle delayed triggers at terminal owners
  • refactor(engine): migrate delayed trigger installers to canonical identities
  • feat(engine): certify actionless prospective progress
  • refactor(engine): route empty combat through transition seam
  • test(engine): cover canonical trigger persistence consumers
  • refactor(engine): make priority mana announcements provider-owned
  • refactor(engine): source priority mana undo announcements
  • refactor(engine): source prepared-copy priority announcements
  • refactor(engine): source priority end-effect announcements
  • refactor(engine): source companion priority announcements
  • refactor(engine): source planar-die priority announcements
  • refactor(engine): source foretell priority announcements
  • refactor(engine): source normal spell priority announcements
  • refactor(engine): source land-play priority announcements
  • refactor(engine): source free-cast priority announcements
  • fix(engine): retain room timing facts in priority preflight
  • refactor(engine): source face-down priority announcements
  • refactor(engine): source transform priority announcements
  • refactor(engine): source room-door priority announcements
  • fix(engine): match opaque priority mana announcements
  • refactor(engine): source activated-ability priority announcements
  • refactor(engine): source ninjutsu priority announcements
  • refactor(engine): source sneak priority announcements
  • refactor(engine): source web-slinging priority announcements
  • refactor(engine): source tap-payment priority announcements
  • refactor(engine): source turn-face-up priority announcements
  • refactor(engine): source equip priority announcements
  • fix(engine): satisfy web-slinging provider lint
  • test(engine): guard priority facade opacity
  • fix(engine): make priority facade guard self-neutral
  • refactor(engine): extract one auto-advance unit
  • refactor(engine): bound auto-advance phase hops
  • fix(engine): box auto-advance waiting outcome
  • refactor(engine): bound untap completion to one phase hop
  • refactor(engine): bound eliminated-turn phase transition
  • refactor(engine): route phase handoffs through one hop
  • test(engine): guard one-hop production phase handoffs
  • fix(engine): validate optional life-cost flow provenance
  • refactor(engine): bind prospective forced-progress permits
  • refactor(engine): permit forced Pact trigger ordering
  • test(engine): initialize forced permit budget idiomatically
  • fix(engine): normalize delayed lifecycle restore boundaries
  • test(engine): satisfy trigger carrier serde invariant
  • test(engine): encode canonical trigger carrier fixture
  • test(engine): declare trusted legacy wire mode
  • fix(tooling): parse macro-declared game state fields

Summary by CodeRabbit

  • New Features
    • Added comprehensive priority-action previews for casting, mana, abilities, payments, transformations, morphs, and other legal actions.
    • Improved automatic progression through actionless priority windows while preserving actionable decisions.
  • Bug Fixes
    • Improved delayed-trigger tracking, ordering, resolution, and cleanup.
    • Prevented stale state after interrupted resolutions, eliminations, phase changes, and terminal outcomes.
    • Strengthened validation for optional life costs and legacy saved states.
  • Compatibility
    • Improved migration and loading of persisted game states and resolution data.

@lgray

lgray commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

@matthewevans can we hold this one so #6886 goes in first - better to refactor after big changes go in.

@matthewevans
matthewevans added this pull request to the merge queue Aug 3, 2026
Merged via the queue into main with commit 4099566 Aug 3, 2026
16 of 18 checks passed
@matthewevans
matthewevans deleted the ship/canonicalize-delayed-trigger-lifecycle branch August 3, 2026 00:30
lgray added a commit to lgray/phase that referenced this pull request Aug 3, 2026
…lper

Two seams the phase-rs#6933 fold analysis flagged that the merge resolution left open.
Both are additive here; nothing in the merge is rewritten.

1. CR 732.2a bound guard, second ingress. phase-rs#6933 split the decode surface: the
   free `decode_persisted_resolution_state` became a delegator, and the two
   authorities are now `GameStateDecode::decode_persisted_resolution_state` and
   `GameStateDecode::decode`. They are genuinely separate — the former
   deserializes `ResolutionStateWire` itself and never calls the latter — and
   `impl Deserialize for GameState` routes through `decode` with
   `GameStateDecodeMode::DirectCurrentRaw`. With the guard on one site only, a
   persisted `LoopShortcut` offer whose wire bound is 0 was refused through the
   persisted path and revived through the bare-`GameState` path.

   A discriminating row lands with it (see loop_shortcut.rs): bare-`GameState`
   decode of `with_bound(0)` must `Err`, with a `with_bound(5)` control and a
   reach-guard that the UNMUTATED fixture decodes bare at all — `DirectCurrentRaw`
   skips the legacy migrations, so without that guard an `Err` would not be
   attributable to the bound. REVERT-PROBE: delete only the new call in `decode`
   and every pre-existing arm still passes while that row flips to `Ok`.

2. `delayed_trigger_payload_matches` is dead. It is base code from the v1
   provenance backfill that phase-rs#6933 replaced; upstream deleted the definition AND
   its only caller together, and this branch adds no caller of its own
   (`git diff 9b97019..ea1b0ac -- game_state.rs` contains no added reference).
   Kept, it is a private fn with zero callers under `-D warnings`. Post-removal
   grep for the name returns 0 hits repo-wide.

Also documents, at the `bind_resolution_scope` caller in stack.rs, why the
CR 603.4 settlement must stay at the caller and never move into the helper:
`analysis/resource.rs` calls that helper on CLONED PROBE BOARDS at five sites,
where running terminal delayed-trigger disposition would mutate lifecycle state
for a board that is only being measured. Comment only; the code is unchanged.

ON THE CR ANNOTATION AT THAT BLOCK, since it is easy to read as drift: the
trigger-event-context block keeps `CR 608.2k`, not the `CR 603.7c` inherited from
before this branch. CR 608.2k — "If an ability's effect refers to a specific
untargeted object that has been previously referred to by that ability's cost or
trigger condition, it still affects that object even if the object has changed
characteristics" — is what the block implements: it stores `current_trigger_event`
so `TriggeringSpellController`/`TriggeringSource` resolve at resolution time.
CR 603.7c is scoped to delayed triggered abilities and zone changes and does not
describe this code. `resolution_prompt.rs` already cross-references CR 608.2k for
this helper.

Assisted-by: ClaudeCode:claude-opus-5
lgray added a commit to lgray/phase that referenced this pull request Aug 3, 2026
…ted payload

Two independent changes to the same file, both consequences of phase-rs#6933.

1. The second-ingress row for the CR 732.2a bound guard added in the previous
   commit. Arms: bare-`GameState` decode of `with_bound(0)` must `Err` naming
   `max_iterations 0`; `with_bound(5)` must load; and a REACH-GUARD that the
   unmutated fixture decodes bare at all, because `DirectCurrentRaw` skips the
   legacy migrations and an `Err` from a fixture that cannot decode at all would
   say nothing about the bound. The pre-existing arms above it cannot stand in
   for this one: they reach `decode_persisted_resolution_state`, and a revert of
   the guard call in `decode` ALONE leaves every one of them green.

   The orphaned `PersistedGameState` import goes with it — the branch's loader
   uses the fully-qualified path, so the merge left the short import unused
   (`-D warnings`).

2. r28_c's TRUSTED arm hand-built `{"state": <bare GameState>}`. phase-rs#6933 made
   `resolution_state_version` a required discriminator and gave only the
   PersistedRaw ingress permission to stamp v1 onto a legacy payload; the
   TrustedEnvelope ingress deliberately stamps nothing, because a trusted
   snapshot is WRITTEN as a versioned envelope and must retain its declared
   compatibility mode. `GameState`'s derived `Serialize` emits no such field, so
   the hand-built value was a shape production never writes and the trusted path
   was right to refuse it. Measured before the fix:
   `Error("resolution state wire is missing a numeric resolution_state_version")`.

   Fixed at the BUILDER, not the assertion: the payload is now constructed
   through `ResolutionStateWire::from_game_state`, which is exactly what
   `TrustedGameStateEnvelope`'s own `Serialize` does, so the arm round-trips the
   shape production writes. No assertion was weakened.

   The decode `.expect` also becomes a labelled panic. The row drives four arms
   (hostile x trusted) and the original message carried no `{label}`, so the
   failure above could not say which arm broke — it had to be re-derived by hand.

Assisted-by: ClaudeCode:claude-opus-5
lgray added a commit to lgray/phase that referenced this pull request Aug 3, 2026
…er controls

Arms 5 and 6 asserted against `{"Delayed":null}`, a wire shape phase-rs#6933 removed.
The live `TriggerFiring` variants (`types/identifiers.rs`) are `"Ordinary"`,
`"LegacyDelayed"`, `{"ReceiptEligible":{token,instance,source_id}}` and
`"UnknownLegacy"`.

Those arms stayed GREEN across the removal, which is the point worth recording:
jq is untyped, so the controls fed themselves a shape the engine can no longer
emit and got it back unchanged. A control that passes on input its subject cannot
produce is not evidence about the subject. The arms were not wrong — they had
stopped being about the engine.

Adds a `ReceiptEligible` sub-arm alongside the unit-variant one. That variant
carries a payload, and a preservation rule written against the unit variants
alone could drop its `DelayedTriggerOrigin` and still satisfy every other arm —
so this asserts the VALUE survives, not just the discriminant.

Measured after the change: DEFINITION_SHAPES, CARRIER_PRESERVED (with
`receipt={"ReceiptEligible":{"token":3,"instance":4,"source_id":7}}`),
STALE_CARRIER_PRUNED, NON_GAMESTATE_SKIPPED and STAGE_BESIDE_DEST all true;
12 fixtures, 0 arm failures.

Assisted-by: ClaudeCode:claude-opus-5
lgray added a commit to lgray/phase that referenced this pull request Aug 3, 2026
…fold

The census pins each producer by `file:line`, and folding upstream phase-rs#6933 grew
engine.rs around one of them: `game/engine.rs:10640 => :11427`, +787, while the
file's whole-file delta over the same range is +1134 — so 787 lands above this
producer and 347 below, which is what a file growing AROUND a mint looks like
rather than one gaining a mint.

Identity re-established at the new coordinate, not assumed. The line at :11427 is
byte-identical by sha256 to `ea1b0ac19:game/engine.rs:10640`, and it is still
inside `begin_pending_trigger_target_selection` (fn opens at :11278) — the
producer this row names. The old coordinate now holds copy-target-slot code that
mints nothing. The other four entries did not move at all, which is the same
set-preservation evidence the earlier rebases in this comment relied on: a census
that had gained or lost a producer could not leave four entries byte-identical
AND in place.

Nothing is weakened. The total stays 37 and the partition stays 5/7/25; only the
coordinate of an unchanged producer is updated. This is the failure mode the row
is DESIGNED to have — it is line-pinned precisely so that a moved or added mint
cannot pass silently — so the red was the instrument working, not a defect in it.

Surfaced by `cargo test -p phase-engine --lib` at 596bdfa; every other gate
stage at that tip was green (fmt, parser gate, clippy -D warnings, phase-ai,
integration, fixture corpus).

Assisted-by: ClaudeCode:claude-opus-5
JacobWoodson added a commit to JacobWoodson/phase that referenced this pull request Aug 3, 2026
…provenance type (phase-rs#6884)

The lifecycle-port merge (phase-rs#6933) canonicalized DelayedTrigger.provenance from
Option<..> to the DelayedInstallIdentity enum. The maintainer's port updated
every production construction but not the building-block regression test added by
this PR, so `provenance: None` in
`multi_fire_combat_damage_consumes_raw_aggregate_not_synthetic` failed to compile
(E0308), which cascaded to red Rust lint + both test shards.

Set it to `DelayedInstallIdentity::LegacyDelayed` (a normal, non-command delayed
trigger — the same value the production install path uses in
effects/delayed_trigger.rs). Test-only change.

Verified against the ported head: engine tests compile clean; the PR's added
tests pass at runtime under the new canonical lifecycle (no behavioral
reconciliation needed) — full lib suite 18323 passed, combat/trigger/delayed
integration sweep 800 passed; cargo fmt + engine clippy (-D warnings) clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
JacobWoodson added a commit to JacobWoodson/phase that referenced this pull request Aug 3, 2026
…ase-rs#6884)

Follow-up to the provenance test fix: the comment I added cited CR 603.7c on
a test-only DelayedInstallIdentity::LegacyDelayed assignment. 603.7c governs a
delayed ability's object reference across characteristic/zone changes, not this
internal lifecycle/test provenance representation. Removed the citation (engine
test scaffolding is not a rule implementation); kept the phase-rs#6933 context note.
Comment-only change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
JacobWoodson added a commit to JacobWoodson/phase that referenced this pull request Aug 3, 2026
…t" trigger (CreateDelayedTrigger + WheneverEvent) with tracked-set/self source binding mechanic (phase-rs#6884)

* Partial: Add Delayed/created "deals combat damage to a player/opponent" trigger (CreateDelayedTrigger + WheneverEvent) with tracked-set/self source binding mechanic

* fix(engine): address delayed combat-damage trigger review blockers (phase-rs#6884)

Resolve the two runtime over-fire paths and test-evidence gaps raised in
review of the delayed "deals combat damage" WheneverEvent mechanic.

Blocker 1 — double-fire (triggers.rs):
collect_matching_delayed_triggers recorded the synthetic per-source
DamageDealt event as the consumed identity while computing its occurrence
from the raw aggregate CombatDamageDealtToPlayer at event_index. Because
filter_consumed_trigger_events_from compares both event equality and
occurrence, the aggregate never matched the consumed set and survived into a
later priority scan, re-firing the trigger. Record events[event_index] (the
raw aggregate) as the consumed identity and keep the synthetic event as
per-firing trigger context only. New building-block regression
multi_fire_combat_damage_consumes_raw_aggregate_not_synthetic fails on the
old code and passes on the fix.

Blocker 2 — empty ParentTarget over-fire (delayed_trigger.rs):
bind_contextual_filter_to_condition rewrites all three WheneverEvent filter
slots, so an "up to N target" parent that chose zero could turn a bare
ParentTarget in valid_card/valid_target (not just valid_source) into
TargetFilter::Any and install an over-firing trigger. The install guard now
inspects all three slots; added sibling fixtures for valid_card and
valid_target.

CodeRabbit maintainability + test evidence:
- Replace the in_delayed_trigger bool with a typed TriggerConditionScope enum
  (Printed/Delayed) per the codebase's typed-enum convention.
- Compute the delayed-trigger one_shot boolean once and reuse it; gate the
  TriggeringSource origin-stamp to one-shot triggers (a multi-fire
  WheneverEvent re-resolves TriggeringSource per firing, so freezing the
  creation-event zone would skip a later firing's zone move).
- Assert the WheneverEventExpiry fields in the three spell-temporal parser
  tests instead of discarding them.
- Strengthen negative tests with positive reach guards: human_torch now pins
  the coverage-honest Unknown parse shape (the possessive subject is not a
  recognized anaphor) and love_on_the_battlefield's three-attacker test
  asserts the defending player's life dropped (attack actually resolved).

Verification: cargo fmt; cargo clippy -p phase-engine --all-targets (clean);
cargo test -p phase-engine --lib (18272 passed); combat/damage/trigger/delayed
integration sweep (798 passed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(engine): scope-evidence class guards for delayed-trigger duration relocation (phase-rs#6884)

Reconcile the parse blast radius the maintainer flagged (14 cards / 4
signatures) with permanent class-level regression coverage rather than one-off
card tests.

Signatures 1-2 (12 cards, "until your next turn, whenever ..." delayed
triggers): the CreateDelayedTrigger duration field change is the intended Gap-C
fix. "until your next turn" relocates from the (previously misattached)
creator-ability duration to the WheneverEvent expiry (UntilControllersNextTurn).
This is a correctness win: before, these triggers had no expiry, defaulted to
EndOfTurn, and were purged at the creating turn's cleanup, so they never fired
on opponents' turns (CR 603.7b). Sig 1 (plain inner effect) leaves the creator
ability with no residual duration; Sig 2 (inner "... until end of turn" buff,
e.g. Jace/Tamiyo) surfaces the residual UntilEndOfTurn on the creator ability.
until_next_turn_delayed_trigger_relocates_duration_to_expiry_across_class pins
both. None of these 12 cards is claimed as newly-supported; they remain
coverage-red multi-ability cards (strict failure retained).

Signatures 3-4 (Alluring Suitor + Love on the Battlefield): the "attack with
exactly two creatures" recognition (AttackersDeclaredCount EQ 2 + you target) is
a class fix. Love has full runtime coverage; added
parse_shape_alluring_suitor_exactly_two_attack_constraint for the sibling so the
class cannot regress independently.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(PR-6884): remove accidental scratch artifact

* style(engine): fix clippy doc_lazy_continuation in scope-evidence test (phase-rs#6884)

The scope-evidence class-guard test's doc comment had a bulleted list
immediately followed by a paragraph; add the blank /// separator so
clippy::doc_lazy_continuation (workspace -D warnings) is satisfied.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(engine)+docs: address second-round review on delayed-trigger PR (phase-rs#6884)

[MED] The "until your next turn" duration test only proved persistence
(delayed_triggers.len) and never exercised firing. Added
until_next_turn_parent_target_rider_fires_on_intervening_turn_then_expires:
installs a ParentTarget rider bound to a real chosen creature (Kang's "any of
those creatures"), drives that creature through unblocked combat against the
controller on the intervening (opponent's) turn via the production pipeline,
asserts the controller drew and took the combat damage, then crosses into the
controller's next turn and asserts the rider was purged at untap. A revert of
the UntilControllersNextTurn expiry purges the rider at the creating turn's
cleanup so it never fires (draw assertion fails); a revert of the untap purge
leaks it (expiry assertion fails). This firing path also exercises the
aggregate->per-source expansion + consumed-identity fix (blocker 1) at runtime.
The existing survives-then-purges persistence test is retained.

[LOW] Removed the false-provenance CR annotations (CR 603.7c / CR 201.5) from
the parser-scope TriggerConditionScope enum and its ParseContext field. CR
603.7c governs a delayed ability's object reference across zone/characteristic
changes and CR 201.5 governs name-based self-reference — neither describes
parser scope or pronoun classification. Per the annotation convention this is
parser scaffolding, not a rule implementation, so it carries no CR citation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(engine): drive Kang firing test through the parsed chapter, not a synthetic def (phase-rs#6884)

[HIGH] The intervening-turn firing test constructed a fresh CreateDelayedTrigger
AbilityDefinition and resolved that, so it could stay green even if the parsed
chain lost its UntilControllersNextTurn expiry, failed to carry the parent target
through the preceding tap/goad clauses, or attached the delayed sub-ability at the
wrong point. Replace it with kang_parsed_chapter_rider_fires_on_intervening_turn_
then_expires, which resolves Kang's ACTUAL parsed chapter via
parse_effect_chain(KANG_CHAPTER) + build_resolved_from_def, supplying a real
chosen creature as the tap target. This exercises the full production seam the PR
changes: parse -> SetTapState/Goad clauses -> parent-target propagation of the
chosen creature into the rider's ParentTarget source (asserted:
valid_source == SpecificObject{chosen}, tapped == true) -> the delayed-trigger
resolver's expiry stamping. It then drives that goaded creature through unblocked
combat against the controller on the intervening turn (asserts the controller drew
and took combat damage) and crosses into the controller's next turn (asserts the
rider was purged). The synthetic install helper is removed.

Second signature (Jace/Tamiyo class): its delayed-trigger install/expiry/purge
seam is identical (pinned by the parse-shape class-guard test). A runtime firing
drive was probed and intentionally NOT added: its inner "it gets -X/-0 until end
of turn" parses "it" to Pump{target: SelfRef} (the source), a pre-existing
inner-effect misparse on that unclaimed card that this PR neither introduces nor
touches — so a Sig-2 firing assertion would test pre-existing behavior, not the
Gap-C expiry seam. Tracked as a separate follow-up.

Verification: cargo fmt --all; cargo clippy --workspace --exclude phase-tauri
--all-targets --features engine/proptest -- -D warnings (clean); Kang test file
passes (4/4).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(parser): remove unsupported CR citations from delayed-anaphora comments (phase-rs#6884)

[MED] Follow-through on the earlier context.rs annotation fix: the same
false-provenance CR citations remained on the sibling parser-scope/anaphora
comments this PR added. Removed CR 603.7c / CR 201.5 (and the CR 109.4 + 608.2c
lead-in) from the four PR-introduced comments:
- oracle_effect/mod.rs: the `Delayed` scope set site.
- oracle_trigger.rs: the anaphoric-subjects gate, the gendered-pronoun→SelfRef
  arm, and the plural-set→ParentTarget arm.

CR 603.7c governs a delayed ability's object identity across characteristic/zone
changes and CR 201.5 governs name-based self-reference; neither describes these
Oracle-text parsing heuristics (mode selection, pronoun/anaphora recognition).
Per the reviewer's instruction the citations are removed rather than swapped for
a nearby delayed-trigger rule that does not demonstrably support the behavior;
the explanatory prose is retained and each is labeled parser scaffolding.

Scope confirmed via `git diff <merge-base>...HEAD`: these four are the only
CR 603.7c/201.5 lines this PR added to the parser; all other such citations are
pre-existing and left untouched.

Comment-only change. Verified: cargo fmt --all clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: retain delayed triggers without moving condition

* test(engine): fix delayed-trigger test construction for phase-rs#6933 provenance type (phase-rs#6884)

The lifecycle-port merge (phase-rs#6933) canonicalized DelayedTrigger.provenance from
Option<..> to the DelayedInstallIdentity enum. The maintainer's port updated
every production construction but not the building-block regression test added by
this PR, so `provenance: None` in
`multi_fire_combat_damage_consumes_raw_aggregate_not_synthetic` failed to compile
(E0308), which cascaded to red Rust lint + both test shards.

Set it to `DelayedInstallIdentity::LegacyDelayed` (a normal, non-command delayed
trigger — the same value the production install path uses in
effects/delayed_trigger.rs). Test-only change.

Verified against the ported head: engine tests compile clean; the PR's added
tests pass at runtime under the new canonical lifecycle (no behavioral
reconciliation needed) — full lib suite 18323 passed, combat/trigger/delayed
integration sweep 800 passed; cargo fmt + engine clippy (-D warnings) clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(engine): drop stray CR citation from provenance test fixture (phase-rs#6884)

Follow-up to the provenance test fix: the comment I added cited CR 603.7c on
a test-only DelayedInstallIdentity::LegacyDelayed assignment. 603.7c governs a
delayed ability's object reference across characteristic/zone changes, not this
internal lifecycle/test provenance representation. Removed the citation (engine
test scaffolding is not a rule implementation); kept the phase-rs#6933 context note.
Comment-only change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Matt Evans <1388610+matthewevans@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
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.

2 participants