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:
card_30058_steal_during_a_run_on_its_server_gives_2_tags)
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):
card_30058_persistent_still_fires_after_the_runner_trashes_it — rez, run the
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.
card_30058_trashing_it_unrezzed_leaves_no_ability_behind — the twin: samerun, AMAZE unrezzed, 0 tags.
card_30058_a_trashed_copy_does_not_fire_for_the_copy_left_installed — asecond (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.