← all tickets

54 — Implement: Esca (36038)

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.

Comments

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.

Surviving review findings (2026-08-03, orchestrator — review cap reached after 3 rounds)

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.