Skip to content

Fix Trade Caravan#5

Open
keloide wants to merge 26 commits into
mainfrom
claude/phase-developer-track-owmkmw
Open

Fix Trade Caravan#5
keloide wants to merge 26 commits into
mainfrom
claude/phase-developer-track-owmkmw

Conversation

@keloide

@keloide keloide commented Jul 21, 2026

Copy link
Copy Markdown
Owner

Summary

Trade Caravan's activated ability Remove two currency counters from ~: Untap target basic land. Activate only during an opponent's upkeep. dropped its timing restriction to Effect::Unimplemented, leaving the ability activatable at any time. parse_activation_during_role_gate handled during an opponent's turn and during your upkeep but had no arm for during an opponent's upkeep. This adds that arm by composing existing building blocks rather than proliferating a new DuringOpponents* restriction sibling.

Implementation method (required)

Method: /engine-implementer (gates applied inline: add-engine-variant existence/parameterization/boundary gate + mandatory final review-impl)

CR references

  • CR 503.1 — the upkeep step (new ParsedCondition::IsDuringUpkeep predicate + its evaluator arm).
  • CR 102.1 — the active player is the player whose turn it is (opponent scope via Not(IsYourTurn)).
  • CR 602.5b — an activated ability's use restriction persists across control changes.

All three grep-verified against docs/MagicCompRules.txt.

Design

Rather than add a DuringOpponentsUpkeep sibling to ActivationRestriction (which already expresses opponent-turn scope via RequiresCondition { Not(IsYourTurn) }, not a dedicated DuringOpponentsTurn variant), the fix composes:

  • ParsedCondition::IsDuringUpkeep — a scope-free upkeep-step predicate, orthogonal to every existing ParsedCondition variant (there was no prior phase predicate).
  • during an opponent's upkeepRequiresCondition { And([Not(IsYourTurn), IsDuringUpkeep]) }, reusing the same opponent-turn composition idiom as the existing bare opponent-turn gate.

add-engine-variant gate result for ParsedCondition::IsDuringUpkeep: Stage 1 DOES_NOT_EXIST (no phase predicate in ParsedCondition), Stage 2 EXTEND_OK (orthogonal — not a sibling cluster), Stage 3 WITHIN_SECTION (CR 503, upkeep step).

Verification

  • Required checks ran clean, or the exact CI-owned alternative is stated below.

  • Gate A output below is for the current committed head.

  • Final review-impl below is clean for the current committed head.

  • Both anchors cite existing analogous code at the same seam.

  • cargo fmt --all — clean.

  • cargo clippy -p engine --all-targets -- -D warnings — clean, 0 warnings (full compile; confirms every exhaustive ParsedCondition match is satisfied).

  • cargo test -p engine --libok. 17440 passed; 0 failed; 6 ignored.

  • cargo test -p engine --test integrationok. 3676 passed; 0 failed; 2 ignored.

  • New tests: activated_ability_opponents_upkeep_restriction_composes_scope_and_step (parser building block, drives production parse(), asserts composed restriction + zero Effect::Unimplemented) and opponents_upkeep_activation_condition_gates_on_step_and_scope (runtime, drives real evaluate_condition across the full turn-scope × step matrix) — both pass.

  • cargo coverage / cargo semantic-auditdeferred to CI: both require a full MTGJSON card-data.json export (156 MB+ multi-request per-set fetch) that isn't feasible in this environment (only a test fixture is present locally). Direct evidence instead: the parsed AST for Trade Caravan now contains zero Effect::Unimplemented and the correct RequiresCondition { And([Not(IsYourTurn), IsDuringUpkeep]) } restriction. Since card_face_has_unimplemented_parts (game/coverage.rs) derives supported precisely from the absence of Unimplemented parts, Trade Caravan is now supported: true, gap_count: 0.

Gate A

Gate A PASS head=c5024c97678be3ffdcbac252e235cd8ed145fdab base=ee061e5c7485619644dc6089976b94a1d29a0202

Anchored on

  • crates/engine/src/parser/oracle.rs:6792value(opponents_turn_activation_restriction(), alt((tag("during an opponent's turn"), tag("during an opponents turn")))): the same value(computed_restriction, alt((tag, tag))) during-role arm the new opponent's-upkeep arm mirrors (combinator family, naming, placement).
  • crates/engine/src/parser/oracle.rs:6803value(ActivationRestriction::DuringYourUpkeep, alt((tag("during your upkeep"), tag("during their upkeep")))): the sibling upkeep-step during-role arm, immediately adjacent, establishing the apostrophe/possessive variant pattern reused verbatim.
  • (supporting) crates/engine/src/game/restrictions.rs:1520 ParsedCondition::IsYourTurn => state.active_player == player and restrictions.rs:1066 CastingRestriction::DuringOpponentsUpkeep => state.active_player != player && state.phase == Phase::Upkeep — the evaluator arms the new IsDuringUpkeep => state.phase == Phase::Upkeep arm mirrors.

Final review-impl

Final review-impl PASS head=c5024c97678be3ffdcbac252e235cd8ed145fdab

Claimed parse impact

  • Trade Caravan (removed from docs/parser-misparse-backlog.md root cause 28)

🤖 Generated with Claude Code


Generated by Claude Code

minion1227 and others added 5 commits July 21, 2026 15:09
…copies (phase-rs#6276)

* feat(engine,client): badge permanents that a copy effect turned into copies

A Phantasmal Image that entered as a copy of Reveillark rendered identically to
the real Reveillark — same art, same name, same P/T — so the board gave no way
to tell them apart (phase-rs#5932).

The board already had a "Copy" badge, but it was gated on a TOKEN-copy
heuristic:

    obj.is_token === true && obj.display_source !== "Token" && !obj.face_down

A real card under a copy effect is not a token, so it never qualified.

No serialized signal existed for the frontend to use instead. `is_copy` means
"not represented by a card" (CR 707.10) and is deliberately cleared when a copy
resolves onto the battlefield (`game/stack.rs`, `game/sba.rs`), so it is
correctly false here. The copy itself lives in a Layer 1a `CopyValues`
continuous effect (CR 613.2a + CR 707.2), not on the object, and the copy
overrides `printed_ref` so even image lookup follows the copied card.

So the engine now classifies it: `DerivedViews::copied_permanents` lists
battlefield permanents whose copiable values a live copy effect supplies,
matched through the same `matches_target_filter` the layer engine uses to pick
an effect's recipients — the projection and the effect that actually rewrote
the object can never disagree. The client unions it with the existing
token-copy case, so the badge now covers the whole class: Clone, Vesuvan
Doppelganger, and every "enters as a copy" permanent, not just token copies.

CR 708.2: face-down permanents are excluded on BOTH sides, since their
characteristics are only those the face-down rules grant and surfacing "Copy"
would leak what one really is. Neither side alone can leak it.

Tests: engine coverage for the projection (flagged under a live effect, empty
on an ordinary board, never for a face-down permanent) and client coverage for
the badge, including the face-down guard. The positive client test is
discriminating — restoring the old token-only condition fails it while the two
guards stay green.

Closes phase-rs#5932

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(engine): gate the copy badge on the layer engine's liveness predicate

Review blocker: the projection treated a STORED copy effect as a live one. A
lapsed effect stays in `transient_continuous_effects` until it is swept, so
Zygon Infiltrator's "for as long as that creature remains tapped" copy ended
the moment its target untapped while the permanent kept displaying the badge.

`gather_transient_continuous_effects` already knew the answer — it rejects a
departed `UntilHostLeavesPlay` host, a `ForAsLongAs` duration that no longer
holds, and a false source condition. Those three gates are now extracted into
`transient_effect_is_live`, which both the layer engine and `derive_views`
call, so a display projection cannot claim an effect is live after the layer
engine has stopped applying it. Extracted rather than duplicated: a second copy
of the predicate would be free to drift, which is exactly the class of bug this
review caught.

`gather_transient_continuous_effects` keeps its existing behaviour — the
condition gate now runs inside the shared predicate, and the retained
recipient-context condition is unchanged.

Also from review:
  * `copied_permanents` is sorted, matching what its doc comment already
    claimed.
  * The client's duplicated `!face_down` check is factored out so the CR 708.2
    guard leads and covers both copy sources.

Tests: a derived-view regression builds Zygon's target-relative `ForAsLongAs`
copy, asserts the badge shows while the target is tapped, then untaps it and
asserts the badge is gone while the effect is still stored — so it exercises
liveness rather than removal. It fails without the gate.

Verified: 17442 lib tests, 3676 integration tests, clippy -D warnings, all clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(PR-6276): exclude merge layer effects from copy badges

A merge representation uses CopyValues for its top component but is not a copy effect. Exclude only the tracked merge layer effect and cover the derived view.

Co-authored-by: minion1227 <romantymkiv1999@gmail.com>

* test(PR-6276): preserve copy badges after merge representation

Prove the merge-layer exclusion is scoped to its tracked effect ID, so an independent copy effect on the merged host remains visible.

Co-authored-by: minion1227 <romantymkiv1999@gmail.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
…hot vintage-proof (phase-rs#6274)

* fix(engine): commit W30 MTGJSON token catalog and make coverage snapshot vintage-proof

The weekly MTGJSON refresh introduced by phase-rs#6237 (vintage gate 2026-07-20)
unfroze a stale token catalog cache. Deterministic regen (byte-identical
across two checkouts) against the 2026-07-20 MTGJSON data adds 13 tokens
(12 Secret Lair Drop printings: Food x8, Treasure, Ooze, Elf Warrior x2;
1 HOB Goblin Army) and 1184 [[token.source_card_refs]] (8637 -> 9821),
and picks up MTGJSON's face-name change 'Undercity // The Initiative' ->
'Undercity'. No tokens removed; coverage stays complete (2858/2858
supported, 1490/1490 rules-text tokens parsed).

analyze_token_coverage_treats_source_defined_pt_as_represented pinned
vintage-dependent absolutes (2845/1480), which go red every week upstream
adds tokens even at 100% coverage. Replace them with invariants
(supported == total, parsed == rules_text) plus non-vacuity floors at
the last-known-good baseline so an empty or truncated catalog cannot
pass vacuously.

Assisted-by: ClaudeCode:claude-fable-5

* test(engine): ratchet token-catalog floors to the committed vintage

Review feedback (phase-rs#6274): the non-vacuity floors shipped at 2845/1480 sat
exactly at the PRE-refresh catalog, so reverting this PR's regen still
passed every assertion — the guard could not detect losing the refresh it
was added alongside. The pre-PR test asserted the parent counts as
equalities (assert_eq!(total_tokens, 2845)) and was green on main, so the
parent catalog measures exactly those floors.

Ratchet all floors to the committed vintage (2858/1490) and add the third
axis already carried on the summary, source_card_refs >= 9821. `>=` still
keeps weekly upstream ADDITIONS green — the false-red phase-rs#6237 introduced —
while any shrink now fails.

The ref floor is the load-bearing one and is deliberately tight: across the
ten recorded revisions of known-tokens.toml, tokens and rules_text are
monotone, and source_card_refs shrank exactly once — phase-rs#6199, a parser PR
that silently dropped 1173 token<->card links (9810 -> 8637) while the
token count GREW past a count-based floor. This regen is what repaired it.

Each floor is probed in isolation, since sequential assert!s let an earlier
conjunct dominate a later one and render it vacuous:
  - catalog reverted to f0ac543^   -> FAIL "token catalog shrank: 2845 presets < 2858"
  - 1 rules_text line removed        -> FAIL rules_text_tokens >= 1490
  - 5 source_card_refs blocks removed -> FAIL source_card_refs >= 9821
Catalog restored byte-identically after each probe.

Floors carry the measured count in the panic so a CI red names the axis and
the delta instead of requiring a local repro.

Assisted-by: ClaudeCode:claude-opus-4.8

* docs(engine): document the provenance seam for the token-catalog floors

The floors observe totals; provenance (committed catalog == tokens-gen output
for the declared MTGJSON vintage) is structurally untestable at test time (the
~560 MB gitignored generator input is absent; the test sees build.rs's embed of
the tracked file) and is verified at the generation seam instead
(gen-card-data.sh temp+cmp+vintage gate). Comment documents the seam and the
by-hand reproduction recipe, per review 4746023659.

Assisted-by: ClaudeCode:claude-opus-4.8
…ring mana payment (phase-rs#5963) (phase-rs#6272)

* fix(engine): fire sacrifice/death triggers for mana abilities paid during mana payment (phase-rs#5963)

A mana ability with a non-tap cost (e.g. Gilded Goose's "{T}, Sacrifice a
Food: Add {C}") activated during mana payment for a spell silently dropped
every observer of its cost. Sacrificing a creature-that-is-a-Food never
fired Scavenger's Talent's "creatures you control die" (L1) or "you
sacrifice a permanent" (L2) triggers.

The PayCost -> CostResume::ManaAbility arm never scanned its cost events for
triggers. The post-action trigger pipeline only runs for Priority resumes
(guarded by waiting_for == Priority), so a ManaPayment/UnlessPayment resume
lost the events. This mirrors the inline scans already done by the
ChooseManaColor and mid-payment ActivateAbility arms: snapshot events before
the cost handler, process_triggers on the new events, surface any
OrderTriggers prompt, and claim the scan for Priority resumes so the
pipeline does not double-fire.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(engine): move mana-ability cost trigger scan to the settlement authority (phase-rs#5963)

The initial fix scanned mana-ability cost events in engine.rs's PayCost ->
CostResume::ManaAbility arm. That was the wrong layer and double-fired:
finish_mana_ability_cost_payment is the single settlement authority for
mana-ability cost events and already scans them for the ChooseManaColor
path, so an extra scan in the reducer double-counted Kilo's becomes-tapped
proliferate trigger under a standalone Relic activation (Pentad grew 3->5
instead of 3->4, breaking kilo_accept_marks_pentad_charge_as_unbounded_display_target).

Revert the engine.rs scan and fix the real gap inside
finish_mana_ability_cost_payment: a direct (non-paused) mana ability whose
root resume is not Priority (ManaPayment/UnlessPayment, i.e. activated during
mana payment) never reaches the post-action trigger pipeline, so its
already-paid sacrifice/discard/exile cost events were dropped. Scan them at
the settlement authority for non-Priority resumes only; Priority resumes stay
with the pipeline so nothing double-fires.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix

* style(PR-6272): hoist mana payment test imports

Co-authored-by: Andriy Polanski <andriy.polanski@gmail.com>

---------

Co-authored-by: Andriy Polanski <andriy.polanski@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
…#6278)

The engine embeds three git-tracked, generated data files at compile time
(known-tokens.toml, oracle-subtypes.json, mtgjson-vintage) that were
regenerated and committed by hand. When MTGJSON publishes new tokens/subtypes
weekly they drift, which periodically turned CI red and required a manual
catalog PR (e.g. phase-rs#6274).

Add a scheduled workflow that force-refreshes MTGJSON, runs the single
source-of-truth generator (scripts/gen-card-data.sh), and opens an auto-merge
PR only when the tracked catalogs change. Mirrors refresh-feeds.yml. The
vintage write-gate makes it self-correcting (no newer input -> empty diff ->
no PR), so it is safe on all three triggers: weekly schedule, manual dispatch,
and after the Clear Caches workflow (which only deletes caches; this re-fetches,
re-processes, and commits the result).

Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
… Caravan)

Trade Caravan's activated ability "Remove two currency counters from ~:
Untap target basic land. Activate only during an opponent's upkeep." dropped
its timing restriction to `Effect::Unimplemented`, leaving the ability
activatable at any time.

`parse_activation_during_role_gate` handled "during an opponent's turn" and
"during your upkeep" but not "during an opponent's upkeep". Rather than add a
`DuringOpponents*` sibling to `ActivationRestriction` (which expresses
opponent-turn scope via `RequiresCondition { Not(IsYourTurn) }`, not a
dedicated variant), compose from building blocks:

- Add `ParsedCondition::IsDuringUpkeep` (CR 503.1) — a scope-free upkeep-step
  predicate, orthogonal to every existing `ParsedCondition` variant.
- Map "during an opponent's upkeep" to
  `RequiresCondition { And([Not(IsYourTurn), IsDuringUpkeep]) }` (CR 602.5b +
  CR 102.1 + CR 503.1), reusing the existing opponent-turn composition idiom.
- Evaluate `IsDuringUpkeep` against `state.phase == Phase::Upkeep`; classify it
  memo-safe in the AI condition filter (reads only apply()-constant state).

Removes Trade Caravan from the parser-misparse backlog (root cause 28).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HwvJgpQpXqutChWLLLGB8E
@github-actions

github-actions Bot commented Jul 21, 2026

Copy link
Copy Markdown

Parse changes introduced by this PR · 3 card(s), 4 signature(s) (baseline: main ee061e5c7485)

🟢 Added (1 signature)

  • 1 card · ➕ ability/where_x_binding · added: where_x_binding
    • Affected (first 3): Kotis, the Fangkeeper

🔴 Removed (2 signatures)

  • 1 card · ➖ ability/CastFromZone · removed: CastFromZone (free cast=yes, target=cards exiled by source)
    • Affected (first 3): Kotis, the Fangkeeper
  • 1 card · ➖ ability/activate · removed: activate
    • Affected (first 3): Trade Caravan

🟡 Modified fields (1 signature)

  • 1 card · 🔄 trigger/DamageDone · changed field valid target: blocking creature
    • Affected (first 3): Kusari-Gama

8 card(s) had Oracle-text changes (errata/reprint) — excluded as non-parser.
New cards in head: 4.
Cards only in baseline: 2.

matthewevans and others added 21 commits July 21, 2026 16:14
…dexing (Plan 04) (phase-rs#6269)

* feat(engine): migrate ability continuation to resolution stack

* feat(engine): migrate repeat-for to resolution stack

* feat(engine): migrate repeat-until to resolution stack

* feat(engine): migrate choose-one-of to resolution stack

* feat(engine): migrate vote-ballot to resolution stack

* feat(engine): migrate per-player zone choice to resolution stack

* feat(engine): migrate per-category zone choice to resolution stack

* fix(engine): retain per-player continuation below prompt

* fix(engine): preserve nested resolution frame order

* fix(engine): migrate resolution stack consumers

* fix(engine): keep resolution frame authority documented

* refactor(engine): migrate ChangeZone resolution frame

* refactor(engine): migrate BatchDelivery resolution frame

* refactor(engine): migrate CounterMoves resolution frame

* refactor(engine): migrate CounterRemovals resolution frame

* refactor(engine): migrate CounterAdditions resolution frame

* refactor(engine): migrate CopyToken resolution frame

* refactor(engine): migrate EachPlayerCopyChosen resolution frame

* fix(engine): parent continuations beneath migrated frames

* fix(engine): preserve nested tranche two frame order

* fix(engine): retain devour owner at nested boundary

* fix(engine): retain nested change zone owners

* refactor(engine): migrate optional effect resolution frame

* refactor(engine): migrate repeated optional payment frame

* refactor(engine): migrate coin flip frame

* refactor(engine): migrate proliferate frame

* refactor(engine): migrate mutate merge frame

* fix(phase-ai): read optional effect resolution frame

* fix(engine): retain direct choice frame ownership

* fix(engine): reject buried direct choice wire owners

* fix(engine): preserve legacy parent wire ordering

* refactor(engine): move draw replacement tails into frames

* refactor(engine): move connive reentry into frames

* refactor(engine): move life assignment into frames

* refactor(engine): move spell resolution into frames

* fix(engine): preserve optional frame takes

* fix(engine): preserve replacement frame nesting

* fix(engine): preserve post-replacement frame parents

* fix(engine): resume paired draw continuations

* fix(engine): retire paused replacement prompt frames

* fix(engine): retain replacement context through promoted tails

* test(engine): cover paired replacement elimination

* refactor(engine): centralize resolution settlement checks

* fix(server): hide resolution frames from player views

* fix(engine): clear token seed after frame drain

* docs(engine): clarify token seed drain authority

* test(engine): cover resolution wire allocator migration

* test(engine): pin resolution wire compatibility contract

* ci: guard resolution-frame boundaries

* fix(engine): bind inline mana overrides to trigger identity

* perf(engine): index inline mana triggers

* fix(ship): restore main's protocol 20 dropped by stale freeze rule

The merge resolution pinned the application protocol at 19 per the
Plan-04 charter's frozen-constants rule, but that rule predates main's
shipped bump to 20 (actor-scoped priority passing, phase-rs#6213). Restore
main's constants and version tests; keep the Phase-4 changelog note
that the resolution-wire pinning added no protocol change of its own,
and retarget the protocol ratchet script to 20.

* fix(ci): drop ripgrep dependency from resolution-frame boundary guard

The guard's embedded Python shelled out to 'rg -l' to list engine
files containing legacy wire keys; ripgrep is not installed on CI
runners, so the lint job died with FileNotFoundError after every
lint step had passed. Scan with a pure-Python rglob over the same
path and pattern instead. Red/green re-verified: clean tree passes,
an injected legacy key in production code fails with exact path:line.

---------

Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
…tency (phase-rs#6286)

The coverage snapshot's ratchet floors (phase-rs#6274) observe three totals, so they
catch a catalog that shrank but not one that was hand-edited, spliced, or
partially rewritten while keeping its counts. Add the two structural
properties of tokens-gen output that ARE checkable at test time:

- ids strictly ascending, matching tokens_gen.rs:106's
  `presets.sort_by(|a, b| a.id.cmp(&b.id))`
- `token_image_ref.preset_id == id` wherever a ref exists, since the generator
  builds both from the same MTGJSON uuid

Both are per-entry properties with no pinned totals, so a weekly refresh that
only adds tokens stays green — the phase-rs#6237 false-red phase-rs#6274 removed does not
come back. Neither establishes provenance (that needs the ~560 MB gitignored
generator input, absent at test time); that limit is stated in-code.

Consistency is asserted only where an image ref exists: a token with no
Scryfall image is legal upstream data, and requiring one would re-introduce a
weekly false-red.

Scope is stated precisely in-code because it is narrow: neither assertion
catches a body-only rewrite (nothing ties body/fidelity/source_card_refs to an
identity) nor a deleted entry (removing from the middle keeps the sequence
ascending) — deletion is the count floors' job.

Also corrects known_token_presets()'s doc comment, which claimed presets are
"sorted by category then id for stable display order". Both halves were wrong:
tokens_gen.rs:106 sorts by id alone, and nothing displays in engine order —
DebugCreateActions.tsx regroups by category and re-sorts by power/toughness/
name. The real reason for id order is a minimal diff on regen.

Assisted-by: ClaudeCode:claude-opus-4.8
* feat(server): channel-aware startup data bootstrap and shell lifecycle flags

Add configurable bind addressing for loopback shell launches.
Add stdin-close lifecycle shutdown for orphan prevention.
Add optional exact-Origin WebSocket handshake filtering.
Add signed release/preview data bootstrap with verified downloads and dev-only fixture gating.

* fix(server): best-effort draft-pools bootstrap, HTTP timeouts, https-only manifests

* ci: slim signed server artifacts, content-addressed data, and continuous preview server pipeline

Publish content-addressed draft pools in deploy and release data pipelines.

Compute and export the deploy engine fingerprint, stamp the preview frontend environment, and dispatch the continuous preview server artifact workflow.

Add three-platform preview server builds with Ubuntu minisign signing, signed manifest-last publication, and current-plus-previous R2 retention.

Publish signed release data manifests and slim binary-only server assets while retiring bundled data archives.

Remove server-data artifact plumbing and Docker data baking so server data bootstraps at runtime.

Sequencing: merge together with, or after, the server bootstrap commit; slim artifacts without bootstrap support would strand fresh self-hosters.

* fix(ci): globally serialize preview manifest publication

* fix(ci): dispatch preview-server.yml from main, not a bare SHA

workflow_dispatch refs must be a branch or tag — a 40-hex SHA fails with
422, so every deploy's preview-server dispatch would have silently missed
(the failure is deliberately non-fatal). The exact source tree is already
pinned by the 'commit' input the workflow checks out. Same pattern as
nightly-release.yml. Also documents the preview image's PHASE_CHANNEL
identity gap (GH phase-rs#6287) where the old comment claimed runtime bootstrap.

---------

Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
* feat(desktop): native engine lifecycle — download, verify, spawn, adopt, GC (Workstream E)

- Add typed remote ensure/stop commands and progress events.\n- Resolve release and preview artifacts with downgrade ratchets.\n- Verify signed manifests and server binaries with the pinned key.\n- Provision writable per-key data from a SHA-256 shared cache.\n- Manage adoption, stdin watchdog shutdown, single-instance, and manifest-diff GC.\n- Scope the commands to remote capabilities and refresh generated schemas.

* fix(desktop): non-fatal native-engine GC, single-instance first, fast-fail health poll

* test(desktop): de-flake migration temp dirs with a per-process sequence

SystemTime nanos alone can collide across parallel test threads on coarse
clocks, making concurrent tests share a temp directory and race the channel
preference file.

* fix(desktop): re-verify PID before SIGKILL, basename process match on BSD, drop redundant Windows pre-delete

Addresses the three Gemini review findings on phase-rs#6283: PID-reuse race between
TERM and KILL, ps -o comm= reporting bare executable names on BSD-derived
systems, and delete-before-rename defeating crash atomicity on Windows
(std::fs::rename already replaces the destination there).

* fix(desktop): never kill an unowned spawn record on app exit

stop_native_engine's exit path fell back to killing whatever PID the
shared on-disk spawn record named, even when this process spawned no
engine — a record another live instance owns. Today the single-instance
secondary hard-exits inside plugin init (verified in
tauri-plugin-single-instance 2.4.3 on all three platforms) so the path
is dormant, but killing a process this instance did not spawn must never
be exit-path behavior. Orphans already self-terminate via
--exit-on-stdin-close; stale records resolve at the next
ensure_native_engine adopt-or-kill.

---------

Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
…lity gate (phase-rs#6288)

The Full-mode server's action-legality gate does an exact containment
check against the enumerated candidate set, but candidate enumeration
emits every cast-family action with CastPaymentMode::Auto — so any cast
submitted with the Manual payment preference could never match, and
manual-mana players could tap lands but never cast anything.

CR 601.2g: the payment mode selects whether the engine auto-pays or
pauses for manual mana activation — a payment preference, not part of
the cast's legality. GameAction::with_canonical_payment_mode() (engine-
owned, Cow-borrowing for the common case) erases Manual before the
membership test; the applied action keeps the submitted mode.

Fixes phase-rs#6275

Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
…oors (phase-rs#6290)

The refresh-card-data.yml test run regenerated known-tokens.toml from a
clean, complete MTGJSON fetch (333/333 token sets, 0 failed) for vintage
2026-07-21, producing 8644 source_card_refs — vs 9821 in the
hand-committed catalog (phase-rs#6274), which came from a developer's local
data/mtgjson/sets/ dir carrying extra reprint set files beyond the
reproducible fetch scope. Every token keeps its source-card names
(103078 -> 103033 name-list lines, 0.04%) and >=1 printing; only
redundant reprint scryfall_ids (e.g. akr/usg/j25 reprints) drop, which
no consumer depends on.

- Land the canonical 8644-ref catalog + vintage 2026-07-21.
- Recalibrate the token_coverage floors from exact-pins (which rejected
  the correct clean regen) to catastrophic-loss backstops with headroom
  (total >= 2700, rules_text >= 1400, source_card_refs >= 8000).
- Add a fetch-completeness gate to refresh-card-data.yml: refuse to open
  a catalog PR unless fetch-token-sets.sh reports 0 failed / 0 skipped,
  making provenance a build-time guarantee instead of relying on the
  engine floor to catch a partial regen after the fact.

Supersedes the bot-opened phase-rs#6285 (stale base).

Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
…to phase-server (phase-rs#6292)

Two distinct causes broke the first executed deploy since phase-rs#6238/phase-rs#6282:

1. setup-rust-toolchain exports RUSTFLAGS='-D warnings' by default, and an
   env RUSTFLAGS overrides ALL .cargo/config.toml rustflags — silently
   dropping the [target.wasm32-unknown-unknown] 16 MiB shadow-stack
   link-arg phase-rs#6238 added, tripping build-wasm.sh's assert_wasm_stack guard.
   Pass rustflags: '' on every wasm-building job (deploy build-wasm,
   release wasm + broker-wasm) so config.toml stays authoritative.

2. phase-rs#6282 gave phase-server a rustls-only reqwest, but the CI builds run
   'cargo build --bin phase-server' unscoped from the workspace root, so
   feature unification folds feed-scraper's native-tls reqwest features
   in, dragging openssl-sys into the musl cross-compile (no OpenSSL →
   build failure) and dynamic OpenSSL into the Docker image (runtime has
   no libssl). Scope every server build with -p phase-server: deploy,
   release (linux + matrix legs), Dockerfile compile stage, Tiltfile.

Verified: cargo tree -p phase-server -i openssl-sys --target
x86_64-unknown-linux-musl finds no path post-fix (present unscoped);
rustflags input semantics confirmed against the action's action.yml.

Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
)

The tauri resource still built phase-server and copied it into
client/src-tauri/binaries/ as a sidecar — removed with the thin shell
(no externalBin remains in tauri.conf.json), so the cmd compiled a full
engine tree to feed a path nothing reads. It also redeclared engine/AI/
wasm/server sources as deps, restarting the whole dev loop on changes
tauri dev's own Rust watcher already handles.

Local shell testing is the devUrl flow: tauri dev starts vite and hosts
the LOCAL frontend in the shell window (frontend's auto_init already
yields :5173 when tauri is enabled). Tilt now only restarts the loop on
Tauri config/manifest changes.

Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
)

The Dispatch Existing Tag Recovery job runs gh workflow run without a
checkout, so gh tried to infer the repository from a local git directory
and failed with 'not a git repository' — the mode-D residual: every
recovery dispatch for a stranded tag silently died (first hit: v0.32.0
after today's wasm-guard release failure).

Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
…he free review pass (phase-rs#6298)

Gemini Code Assist has been sunset, so the review tooling was leaning on a bot
that no longer answers. Replace that dead dependency without adding per-PR token
cost by promoting the free external reviewer for this public repo.

- pr-contribution-handler: rewrite "Gemini review handling" -> provider-neutral
  "Independent review pass". Drop the @gemini-code-assist trigger and quota logic
  (summoning a dead bot burns a round-trip and posts noise). CodeRabbit is the
  free external pass; local /code-review is a risk-scaled fallback only.
- review-impl: step 4 now assumes NO external bot pre-screened the PR by default,
  so its own lenses are the complete review, not a supplement to a backstop.
  Generalize the remaining Gemini-specific mentions (COMMENTED/reviewDecision
  behavior, severity-reconcile checklist) to any bot.
- Add .coderabbit.yaml: chill profile, generated-artifact path_filters ported
  from .gemini/config.yaml, and 10 layered path_instructions distilled from
  CLAUDE.md, the .gemini styleguide, and the review-impl lenses (Rust idioms,
  CR annotations, engine-owns-logic, parser nom mandate, enum parameterization,
  frontend display-purity + i18n, AI classifiers, transport hidden-info, feed
  safety, test adequacy).
- Remove now-orphaned .gemini/config.yaml and .gemini/styleguide.md; their review
  substance is preserved in .coderabbit.yaml.

The pr_review.py gemini-case-finding-refuted signal alias is intentionally kept:
it normalizes historical events already in the append-only log.

Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
…sys feature unification (phase-rs#6299)

Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
…-rs#5952) (phase-rs#6296)

* fix(engine): don't force a MustAttack carrier to attack itself (phase-rs#5952)

* fix

---------

Co-authored-by: Andriy Polanski <andriy.polanski@gmail.com>
Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
Co-authored-by: andriypolanski <andriypolanski@users.noreply.github.com>
…se-rs#6300)

* fix(parser): gate DamageDone triggers on combat-status recipient (Kusari-Gama)

* fix(PR-6300): harden blocking-recipient review

Correct the blocking-creature CR citations and add a parsed-trigger-to-runtime-matcher regression that rejects player damage while accepting damage to a blocking creature.

Co-authored-by: andriy-polanski <andriy-polanski@users.noreply.github.com>

* test(PR-6300): cover nonblocking damage recipient

Co-authored-by: andriy-polanski <andriy-polanski@users.noreply.github.com>

---------

Co-authored-by: Andriy Polanski <andriy.polanski@gmail.com>
Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
Co-authored-by: andriy-polanski <andriy-polanski@users.noreply.github.com>
…lass) (phase-rs#6302)

Self-library peek-and-cast cards (Kiora Sovereign of the Deep, Aetherworks
Marvel, Construct a Cosmic Cube, Perception Bobblehead, Svella Ice Shaper,
Velomachus Lorehold) previously routed "cast ... from among them" through the
LingeringPermission driver: the looked-at cards were exiled, the free cast was
never offered, and the rest were never bottomed.

- Parser: seed chain_prior_self_library_peek from the raw bare-private-peek
  Dig shape via a single shared effect_is_bare_private_peek predicate (also
  used by the assembly pure-peek lowering, so the two sites cannot drift);
  flip the "from among them" fall-through to CastFromZoneDriver::
  DuringResolution when the chain has a self-library peek and no exile
  producer (CR 608.2g).
- Parser: parse the "with mana value N or less/greater" dig-peek suffix into
  a typed cast-permission constraint (fixes Perception Bobblehead's dropped
  MV<=3 cap; Founding the Third Path stays constraint-free).
- Resolver: park an EffectZoneChoice on the Library for eligible candidates,
  freeze dynamic constraint refs (X, power) to Fixed at binding time
  (CR 608.2h), cast the chosen spell during resolution, and bottom the rest
  in a random order on accept, decline, and zero-eligible paths (CR 401.4).
- Resolver: gate the pre-injected-target library route on
  references_exiled_by_source() so Planetarium of Wan Shi Tong's
  ParentTarget flow keeps its direct free cast.
- Visibility: the prompt player sees the looked-at Library cards; opponents
  get redacted placeholders across serde round-trips (CR 701.20e, CR 400.2).

Fixes the Discord bug report (thread 1529132485668245575).

Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
… paths-filtered tips (phase-rs#6303)

Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
…ient routing (phase-rs#6305)

* feat(desktop): pinned native-engine WebSocket bridge commands and JS shim

* test(desktop): cover bridge shim ordering contract and bridge failure paths

* feat(client): route native-AI games through the shell bridge with WASM fallback

* fix(client): use camelCase AI seat wire fields and surface native engine death

* fix(client): surface the connection-lost banner when the native engine dies mid-game

* test(client): index mock calls without Array.at to satisfy type-check

---------

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.

7 participants