Status: done
What to build: Implement 35048 — Proprionegation (elev agenda, adv 4, 2 points, Security). Card text: "When you score this agenda, place 1 agenda counter on it. Hosted agenda counter: The Runner moves to the outermost position of Archives. *(They approach any ice in that position.)* Use this ability only during a run."
Follow docs/adding-a-card.md: hook block in rust/netrunner-core/src/cards/elev.rs, ≥1 card_35048_* test, flags in game/cards_impl/elev.json.
Watch out for: the counter ability is a mid-run redirect — the Runner's position and attacked server change to Archives' outermost piece of ice. Check what the engine's run state supports before promising full fidelity; if a faithful redirect needs new run-state plumbing, that design question goes in a comment here before approximating. On-score counter placement plus the paid-ability window ("only during a run") are the core to test.
The redirect is faithful — no approximation. RunState already carries the
two fields the redirect needs, target and position, and both are plain
mutable state. So use_proprionegation re-points the run at Archives and hands
straight back to advance_run_position, the engine's own "the Runner is now
approaching X" path: it does the breaker-strength reset, the approach log line
and the priority handoff, so a redirected approach is indistinguishable from a
normal one. Rewinding to the pre-run -1 and letting that function advance one
step lands on position 0. Position 0 *is* the outermost ice: install does
server_ice_mut(..).insert(0, ic), so the newest and therefore outermost piece
sits at index 0, and a run walks that vector front to back. An unprotected
Archives falls through the same branch a fully-passed run takes, into the
approach-server window — which is what "the outermost position" means for a
server with no ice. Per-approach state (broken_subs,
fired_subs, encounter_breaks, jack_out_window, ice_str_debuff) is
cleared, since the old approach is over.
Two things the engine *did* need:
1. Counters on scored agendas. corp.scored_agendas was Vec<Arc<Card>>,
with nowhere to hold "place 1 agenda counter on it". It is now
Vec<ScoredAgenda> (card + counters). The zone is push-only, so a score-area
index names the same copy for the rest of the game — which is what lets the
action name a copy by index and what makes the MCTS key stable. Ticket 53
(Sericulture Expansion) hosts agenda counters too and should reuse this.
The counters are state, so they are carried by the canonical snapshot
(corp.scored is the one corp zone that is not a bare code list — its
entries are {code, counters}) and by the client projection, where the
score-area pile renders the usual [n] counter badge. Scored agendas and
their counters are public to both sides, so nothing is redacted.
2. A paid-ability window for score-area cards. Every existing offer registry
is keyed to an *installed* card (InstalledId + rig/root slot); a scored
agenda has neither. New registry scored_agenda_offers, dispatched from the
corp's two windows inside a run in legal::run_actions (approach-ice and
approach-server). Those two windows are the whole of the corp's priority
during a run in this engine, so they are exactly what "use this ability only
during a run" comes to here.
Deliberately not done: turn_flags.mark_run is *not* re-marked for
Archives. The run was initiated on the original server and the run-start
triggers already fired for it; a mid-run redirect changes the attacked server,
not the fact of "you ran server X this turn".
AI: the rule-based corp AI never spends the counter (the offers are appended
after each window's own actions, so its actions[0] fallback still picks the
plain pass). Per docs/adding-a-card.md that is a follow-up ticket, not part of
the bar — MCTS sees the action and can learn it.
cargo test --workspace green (164 core unit tests, incl. 7 card_35048_*, +
5 AI invariants); Python suite green (186 passed) after make rust-py.