Status: fixed
Severity: High. Found in review of ticket 45's implementation; verified against the code.
The bug: action_key now merges PlayOperation by card_code
(rust/netrunner-core/src/ai/mcts.rs:209), while a Runner-root search
reshuffles corp hidden information every iteration
(ai/determinise.rs:31–32). During selection, the single-tree engine replays
the chosen child's stored Action — original card_index and all —
directly via apply_action on the freshly determinised state
(ai/mcts.rs:332–335), instead of resolving the current legal action for
that key.
A stored PlayOperation { card_index: 0, card_code: "..." } therefore
applies whatever card the new determinisation put at HQ index 0.
play_operation (engine.rs:446–463) checks clicks, index range, and
credits but not card type, so a shuffled asset or agenda can be charged,
removed from HQ, logged as played, given on_play hooks, and placed faceup
in Archives. This corrupts rollouts and poisons the statistics of a tree
edge that claims, by key, to represent a specific operation.
Scope is wider than PlayOperation. The stale-payload replay predates
ticket 45 — select has always replayed stored actions across
determinisations (corp install_card hand indices, the ticket-43 index keys
during opponent-decision descent, discard_card, …). Pre-45 the damage was
mostly mis-attributed statistics; ticket 45's code keys turned it into
engine-state corruption with a type confusion. Fix the class, not the
symptom.
Mirror mcts2, which already solved this: after UCB1 picks the best child,
resolve the action to apply from the current state's legal_actions by
matching action_key, not from the stored node
(ai/mcts2.rs:313–320 — "Use the current determinisation's action object
for this key: same key, payload valid for this state").
In select (ai/mcts.rs):
(the legal set is already computed there for the untried-key check — reuse it).
argmax_first picks best_idx, look up that child's key in thecurrent legal set and apply the *current* action object. Multiple legal actions sharing one key (duplicate copies of the same operation in HQ) are equivalent by construction — first match is fine.
apply the stored action; count it via ErrorCounters and terminate the
descent as the existing Err arm does today. (The single-tree engine has
no availability-based subset selection like mcts2; not introducing it here
keeps the fix minimal — note the asymmetry in the module docs.)
Add the missing type guard to play_operation in engine.rs: return
illegal(...) unless the card at card_index is an operation. legal.rs
never generates the action for non-operations, so this only defends against
stale replays and bad wire input — which is exactly what just happened.
Check whether sibling handlers trust an index the same way (e.g. score/
advance already validate their targets; anything that doesn't gets the same
one-line guard).
select applies only actions resolved by key from the current
determinisation's legal set; the stored child Action is used solely for
key identity.
PlayOperation with a staleindex whose determinised HQ has a non-operation at that index; assert the wrong card is not played (pre-fix this corrupts state, post-fix the edge resolves to the correct card by code or the descent terminates).
play_operation rejects non-operations; unit test included.select note the by-key resolution and why (this ticket),matching the existing mcts2 comment.
cargo fmt, cargo clippy --workspace -- -D warnings, and
cargo test --workspace pass from rust/; uv run pytest -q green.
2026-08-02: Filed from maintainer review of ticket 45's implementation. All four claims verified against the code before filing. mcts2 is unaffected (it already resolves by key); the single-tree engine is the only replay site — expansion and rollout both draw actions from the current legal set.
2026-08-02: Fixed.
select (rust/netrunner-core/src/ai/mcts.rs) now builds a
(key, &Action) table from legal_actions(&state) once per descent step —
the same table that answers the untried-key check — and, after argmax_first
picks best_idx, applies the action carrying that child's key in *this*
determinisation. The stored child Action is only ever read through
action_key. When the chosen child's key is not legal here, the descent is
counted in ErrorCounters (as EngineError::Illegal) and terminated; the
stale payload is never applied as a fallback. The module docs on select
carry a "By-key resolution (ticket 47)" section explaining the reshuffle, the
ticket-45 type confusion it caused, and the deliberate asymmetry with
mcts2's availability-based subset selection.
Hardening in rust/netrunner-core/src/engine.rs: play_operation rejects a
non-operation at card_index. Two siblings trusted an index the same way and
got the same one-line guard — play_event (non-event in the grip) and the
Corp install_card (non-ICE in the as_ice branch, non-agenda/asset/upgrade
in the server branch, mirroring install_runner_card_core's existing
else if !card.is_resource() guard). score_agenda and advance_card
already validated their targets; the Runner install path already did too.
Tests: select_resolves_stale_play_operation_index_by_key builds a root
covering every legal key, rewrites the play_operation:op1 child's payload
to a stale card_index: 0 (an agenda in this determinisation), makes it the
UCB1 favourite, and asserts the operation at index 1 is what gets played with
the agenda untouched — verified to fail against a stored-payload select.
select_terminates_when_the_chosen_key_is_not_legal_here covers the
no-legal-action arm. Three engine unit tests cover the type guards.
cargo fmt, cargo clippy --workspace --all-targets -- -D warnings,
cargo test --workspace (160, was 155) and uv run pytest -q (134) all
green.