Status: done
What to build: Implement 36038 — Esca (vp asset, cost 0, Ambush). Card text: "While the Runner is accessing this asset in R&D, they must reveal it. When the Runner accesses this asset, they lose 1[credit]. If they are tagged, do 1 net damage."
Follow docs/adding-a-card.md: hook block in rust/netrunner-core/src/cards/vp.rs, ≥1 card_36038_* test, flags in game/cards_impl/vp.json.
Watch out for: on-access triggers fire on access from any zone (R&D, HQ, remote — check Archives interaction against how existing ambushes behave; unlike Byte!, Esca has no Archives carve-out). "Lose 1[credit]" floors at 0, it is not a cost. The R&D reveal rule likely already has plumbing from Urtica Cipher (30045, implemented sg ambush) — follow its pattern. Tag check happens at resolution time.
The zone question had to be answered in the engine, not in the card.
ON_ACCESS was dispatched from the *remote* branch of engine::access_card
only, and its hook signature (fn(&mut GameState, InstalledId)) said why: a
card accessed in HQ, R&D or Archives is not installed and has no id. So an
ambush in a central did nothing at all. Esca has no zone restriction, so the
dispatcher now fires for the accessed card in every zone and hands the hook an
AccessedCard { card, id: Option<InstalledId>, from: ServerId } — the source
zone travels with the trigger (ticket 55's Byte! reads from for its Archives
carve-out) and the id is present exactly when a remote supplied one.
Urtica Cipher (30045) inherits that fix: accessed in a central it deals its
base 2, since the copy in hand or deck hosts no advancement counters
(AccessedCard::advancement_tokens returns 0 without an id). Recorded in
PORT-DELTA.md under post-port fixes, and covered by an added HQ-access leg on
card_30045_access_deals_2_net_plus_1_per_advancement.
That has a visible consequence for tests/ai_invariants.rs: its checkpoint
seed 6 now flatlines the random-policy Runner on turn 2 (two HQ accesses, two
Urticas, 39 steps — a legitimate game, just too short to checkpoint at 250).
Seed 11 replaces it, running 559 steps where seed 6 ran 543.
The accessed copy's identity has to ride on the queue. The first cut
recovered AccessedCard.id at access time by scanning the target remote for a
card with a matching code, which is not an identity: the CardDb interns cards
by code, so two Urtica Ciphers in one remote are the same Arc<Card>, and both
accesses resolved against the first copy's advancement counters. The access
queue now holds QueuedAccess { card, installed }, filled with ic.id in the
remote branch of build_access_queue, so the hook sees the copy the Runner
actually accessed (card_30045_two_copies_in_one_remote_keep_their_own_advancement).
The reveal clause does need a hook — it is aimed at the Corp. The first
cut read "While the Runner is accessing this asset in R&D, they must reveal
it" as already true, since access_card logs the accessed card by title. It
is not: the Runner is the one who already knows, and
server/serialize.py::_redact_log rewrites Runner accesses: X (from rd). to
an anonymous line in the *Corp's* view, precisely because the Corp must not
learn which card off the top of R&D was hit. The reveal is what suspends that.
engine::reveal_accessed_card therefore logs a separate public line —
Reveal: Esca (accessed from rd) — public information. — that no redaction
rule touches, and Esca calls it only for ServerId::Rd.
A log line alone would not have been enough either, in both directions. The
serialized *run* view handed the Corp pending_card/accessed unredacted, so
the run panel printed "Accessed: <title>" for every R&D access and the reveal
would have been decorative; and last_run_accessed handed over the same titles
after the fact. Both are now Corp-gated on the same rule the log already used,
which is why the reveal is engine *state* (RunState.revealed, in the
snapshot) and not only a string: it is what lifts that gate for this one card.
revealed reads as "public information this run", so the two other ways an
R&D access goes public — stolen to the score area, trashed to archives face
up, both already named in the Corp's log — record themselves there too, and
the run panel does not contradict the log.
tests/test_reveal.py pins the whole path — a real game with an all-Esca corp
deck driven through the PyO3 bindings to an R&D access, then serialized for the
Corp, which reads the reveal, the anonymized access line, and Esca named in the
run panel — against a Hedge Fund control that stays hidden.
Trashing the accessed copy. QueuedAccess carried the id into the
trigger, but the *pending* access threw it away again (`pending_card:
Option<Arc<Card>>), and remove_accessed_card` removed the first card in the
remote with a matching code — so declining one Urtica and trashing its twin
deleted the wrong copy. RunState.pending_access now holds the whole queue
entry, and a remote removal goes by InstalledId (centrals keep the code
match: nothing there distinguishes two copies). Same lookup fixed in
ai/mcts.rs::heuristic_filter, which was weighing the first rezzed copy's
advancement counters against the access of a different one.
No LETHAL_ON_ACCESS twin. That AI-facing figure is computed from an
InstalledCard alone, and whether Esca deals its damage depends on the
Runner's tags at resolution time, which the signature cannot see. Esca is on
the corp AI's AMBUSH_CODES list instead, so the AI does not burn the reveal
by rezzing it during an approach.
Ticket 54 landed as DONE-WITH-FINDINGS: commits 801758b, 592b663, ce595ba
are on the branch and both suites are green, but a third independent review
round still reported the following. They are recorded here rather than fixed.
1. P1 — R&D privacy leaks between identical cards. rust/netrunner-core/src/engine.rs:776
records public access by *card code*, and server/serialize.py:1398 applies that
status to every accessed/pending card with that code. If the Runner trashes one
R&D asset and then accesses but declines another copy of the same asset, the
second, private access is exposed to the Corp. Public/revealed state must
identify an access *instance*, not a card code.
2. P2 — central Urtica accesses can be incorrectly filtered as lethal by MCTS.
rust/netrunner-core/src/ai/mcts.rs:475-480 handles a central queued card by
finding any rezzed remote with the same code and using *its* advancement
counters. An HQ/R&D Urtica should deal only its base 2 net damage, but a
separately installed, heavily advanced Urtica can make the AI treat the central
access as lethal and avoid it.
3. P2 — revealed cards disappear from the Corp's post-run card data.
server/serialize.py:388-389 unconditionally suppresses last_run_accessed
for the Corp, including Esca after its forced public reveal. This contradicts
the new public-information model and stops the client's post-run card lookup /
tooltips resolving cards the Corp was explicitly shown.
Update 2026-08-03: all three findings — plus a fourth found in human review
(the zone-widened ON_ACCESS dispatcher makes Urtica Cipher deal central-access
damage its "while it is installed" clause forbids; this ticket's comment above
recorded that as an inherited *fix*, and the HQ test leg, PORT-DELTA.md entry
and ai_invariants seed swap enshrined it) — are promoted to ticket 62
(62-access-instance-identity-and-urtica-zone-fix.md), status ready-for-agent.