← all tickets

63 — AMAZE Amusements: Persistent must survive being trashed during the run

Status: done

What to fix: AMAZE Amusements (30058) is printed with the Persistent keyword: "Persistent → Whenever a run on this server ends, if the Runner stole any agendas during that run, give the Runner 2 tags. *(If the Runner trashes this card while accessing it, this ability still applies for the remainder of this run.)*" The engine loses the trigger in exactly the case the reminder text names.

Found 2026-08-03 by the external-review bake-off (gpt-5.6-sol) over the ticket-54 diff; verified against the code. Pre-existing — not introduced by ticket 54, but ticket 54's per-copy trash path exercises it directly.

Where it breaks (two independent gates, both must change):

1. engine.rs:2440-2447 — the corp-side run-end hook collection iterates remotes' *currently installed, rezzed* content. A copy trashed during the run is no longer installed, so hooks::run_end is never dispatched for it. 2. cards/sg.rs:1607-1618 (amaze_run_end) — bails when state.installed(id) is None and when the id is no longer in the target remote's content, so even a dispatched call for a trashed copy would return early.

Failure scenario: remote holds a rezzed AMAZE Amusements and an agenda. The Runner runs it, pays to trash AMAZE during access, then steals the agenda in the same run. Run ends: no run-end hook fires for AMAZE, the Runner takes 0 tags. Correct: 2 tags.

Scope / design sketch: the run needs a record of persistent run-end abilities whose cards left play mid-run — e.g. when a rezzed card with a run_end hook is trashed during the run on its own server, push (code, server) onto a RunState list that end_run walks after the installed-card pass. Gate on *rezzed at the time of trashing*: an AMAZE that was never rezzed had no active ability, so trashing it unrezzed must not queue the trigger. Today AMAZE is the only Persistent card in the pool, but build it as the Persistent mechanism, not an AMAZE special case, and note it in docs/adding-a-card.md.

Watch out for:

drives hooks::run_end directly with the card still installed — it cannot catch this. Add an engine-level test: rez, trash during access, steal, assert 2 tags at run end; and a twin asserting *no* tags when the trashed copy was unrezzed.

amaze_run_end) exists to stop duplicate copies both firing; the Persistent path must preserve that (one trigger per physical copy that was active on the run's server).

---

Landed 2026-08-04. Both gates, built as the Persistent keyword rather than as an AMAZE special case. Rust 266, Python 252.

The shape. Persistent is a registry marker — `reg.card("30058") .persistent().run_end(amaze_run_end), alongside .trojan()` in the static-data registries — and the engine does the rest:

1. The record. engine::trash_accessed_card (new; see below) calls record_persistent_trash *before* the copy leaves the board, while it can still be read: a rezzed Persistent card being trashed during access pushes PersistentAbility { id, code, server } onto RunState::persistent_trashed. Gated on rezzed at the time of trashing, exactly as the ticket asked — an ability that was never active has nothing to survive its card. The record dies with the run, which is precisely how long a Persistent ability outlives its card. 2. The dispatch. end_run snapshots the list into RunSummary (like every other detached run fact) and walks it right after the installed-card pass. Double-firing is impossible by construction rather than by a filter: the trashed copy is gone from the board, so exactly one of the two passes can reach any given physical card. The per-copy rule from PORT-DELTA holds — the record is keyed by InstalledId. 3. "This server." engine::run_end_server(state, id, summary) answers where a RUN_END trigger's card is *or was*: the record first, then GameState::server_of (also new — the server an installed corp card belongs to). amaze_run_end is now three lines of gate: steal, then run_end_server(..) == Some(summary.target). Its old body looked itself up in the target remote's content, which is the second gate the ticket named.

Trash paths, deduplicated. trash_card (credits) and carnivore_trash (30003's grip payment) had the same five-step tail copy-pasted. They now log their own payment and hand the access to engine::trash_accessed_card, which owns the whole post-trash sequence — Persistent record, public reveal, remove_accessed_card, ON_ACCESS_TRASH, complete_access. A future third way to pay cannot half-remember it. This runs straight through ticket 62's access machinery: the record keys off QueuedAccess::installed() (the AccessOrigin of the copy actually accessed), and removal is still ticket 62's.

Tests (cards/sg.rs, engine-level, no hook called by hand — the run ends because the access queue empties):

remote, access AMAZE, pay its 3 to trash it, then access and steal the agenda: 2 tags, one log line, and state.run.is_none() asserted so a test that never ended a run cannot pass.

run, AMAZE unrezzed, 0 tags.

second (unrezzed — uniqueness forbids two rezzed copies) AMAZE left in the server adds nothing: 2 tags, not 4.

Each was checked against each gate: disabling the end_run dispatch, the run_end_server call, or the rezzed check makes exactly the expected tests fail. The old hook-level test is kept — it still covers the steal-less and wrong-server cases the engine-level ones don't reach.

Does anything else share the code path? Yes, and honestly: two other Persistent cards are in the supported packs — Mahkota Langit Grid (35082, elev) and Flagship (36064, vp) — both currently unimplemented. Neither is a RUN_END trigger: they are continuous effects (a +2 trash-cost modifier on assets in the root; "the Runner cannot access more than 1 card other than this upgrade"), so implementing either means teaching its *effect site* to consult persistent_trashed too. The record is deliberately generic — every rezzed Persistent card trashed during access lands in it, whatever hooks it registers — so only the dispatch is RUN_END-shaped. docs/adding-a-card.md now says exactly this under "Keywords are printed text too", and PORT-DELTA.md records the fix.

Hidden information: nothing new is revealed. A trashed card is face up in Archives and rez status is public, so both players already know everything the record holds. The snapshot carries run.persistent_trashed (code + server; the ids would normalize to <departed> by construction) sparsely, the way turn_flags is written, so traces recorded before the keyword existed still compare byte-identical and SNAPSHOT_SCHEMA stays at 2.

Nothing in the ticket was skipped or disagreed with. One judgement call beyond it: the trash_card/carnivore_trash extraction, taken because the Persistent hook needed a single choke point and copy-paste was how it would have gained only one of the two.