Status: done
Where this came from: ticket 61's review panel, round 3 — the nine findings (P2 items 5–10, P3 items 11–13 in that ticket's Comments) that survived the review cap but were *not* P1 enough to join ticket 64. All were verified against the source by the orchestrator; none is a regression from 61. Filed here so they do not rot in a closed ticket's comments.
Tickets 61 and 64 are the context: hooks::can_rez gates every rez path,
GameState.vanilla carries the ruleset over PyO3, and the free-rez window
(Send a Message) validates submitted actions against its frozen candidate list.
1. free_rez_options advertises a rez that apply rejects.
rust/netrunner-core/src/legal.rs:594-605 re-checks hooks::can_rez but not
!ic.rezzed, while engine::free_rez errors with "That ICE is already
rezzed". If anything rezzes a candidate while the window is open,
legal_actions offers an unappliable action — the advertised-action
invariant breaks and clients/AI can loop on it. mycoweb_can_rez does check
unrezzed; make the free-rez path match. (luna)
2. A stale free-rez candidate on a removed remote panics.
rust/netrunner-core/src/engine.rs:2814-2834 validates
candidates.contains(...) and only then calls
state.server_ice(server_key).len() — and state.rs:952-957 indexes
self.corp.remotes[i] directly, so a frozen candidate naming a remote that
no longer exists panics instead of returning an illegal-action error. The
validation was added precisely to make stale/replayed actions safe; finish
the job. (luna)
3. The PyO3 vanilla handoff has no behavioral test.
tests/test_unimplemented_cards.py:84-93 only asserts a game built with a
vanilla list can deal an opening hand, and
tests/test_engine_construction.py:118-130 monkeypatches the Python Game
symbol and never crosses PyO3. Emptying vanilla_codes inside
rust/netrunner-py/src/game.rs:122-128 leaves both green while production
refuses to rez a deliberately vanilla ice. Needs a real hookless fixture ice
that rezzes *with* the list and is refused *without* it. (terra, luna)
4. ai_invariants.rs evaluates a different ruleset than production.
rust/netrunner-core/tests/ai_invariants.rs builds states with
vanilla: Default::default() while the server passes
manifest.vanilla_codes(). The day a vanilla ice enters a starter deck,
MCTS legality/determinisation/rollout invariants are checked against a
ruleset no real game uses. True today only because no starter deck has one.
(terra, luna)
5. Ping is tested through hooks::on_rez, never through a rez path.
rust/netrunner-core/src/cards/sg.rs:3799-3822 calls the hook directly, so
nothing pins that rez_ice/rez_card/free_rez set rezzed before firing
it or preserve the active run — exactly the seam ticket 64's finding 4 falls
through. (Ticket 64 fixes the lifecycle; this item is the missing end-to-end
test coverage, and is best done alongside it.) (luna)
6. Nothing exercises the retired-ETR behavior through a real encounter.
hooks.rs:544-568 / engine.rs:1938-2042: the lookup test uses an
arbitrary unregistered code, so a registered ice that loses its
.ice_subs(...) would encounter as a silently sub-less ice with no
card-specific test failing. (luna)
7. The constructor guard misses aliased constructors.
tests/test_engine_construction.py:37, 79-90, 113-115 — the pattern matches
only a callee literally named Game; import Game as RustGame or
factory = netrunner_engine.Game evades it while
test_the_guard_would_notice_a_new_caller claims to cover "every spelling".
Anchor the pattern to the name actually bound by ENGINE_IMPORT.
(glm, terra, luna)
8. Replay accepts malformed header.vanilla silently.
rust/netrunner-replay/src/main.rs:95-112 — filter_map drops non-string
entries, so {"vanilla": [30055]} or ["30055", null] yields a partial or
empty set with no warning, while a *missing* field warns loudly.
vanilla_from_header has no test at all. (terra, luna, glm)
9. Decision-presence booleans are not gated to the decider.
server/serialize.py:427-432 — pending_planogram, pending_reality_plus,
pending_precision_design, pending_retribution and pending_wildcat are
_has_decision(...) for both sides. Payloads are gated (ticket 61 round 2);
the *existence* of an opponent's optional decision is still public, which can
reveal that a private trigger fired. Decide whether that is acceptable and
either gate it or pin the choice with a test. (luna)
Blocked by: none. Item 5 overlaps ticket 64's finding 4 (Send a Message post-run rez) — whoever works both should do 64 first or together.
Suite green at close: cargo 284 (was 275), pytest 261 (was 257). Each fix was confirmed by temporary revert — the new tests fail without it.
1. free_rez_options re-checks !ic.rezzed (legal.rs). The listing now
asks everything engine::free_rez will ask, so an advertised free rez is an
appliable one. Pinned by card_30069_stops_offering_a_candidate_that_got_rezzed_meanwhile,
which rezzes the candidate through the ordinary paid-ability rez while the
window is open and asserts the listing and apply agree.
2. GameState::try_server_ice — the non-panicking server_ice, used by
both the free-rez listing and engine::free_rez. A frozen candidate naming
a remote that is gone is now an illegal action.
(card_30069_free_rez_on_a_vanished_remote_is_refused_not_a_panic; without
it the test panics in state.rs, in legal_actions, before apply is
reached.)
3. The PyO3 vanilla handoff has a behavioral test.
test_the_vanilla_list_is_what_decides_whether_a_hookless_ice_rezzes builds
the *same* unimplemented ice on the same board twice through the raw
constructor — once with vanilla=[], once with vanilla=[that code] — and
asserts it is neither offered nor accepted under the first and both under
the second. Confirmed by rebuilding netrunner-py with vanilla_set
returning Default::default(): only this test goes red.
4. ai_invariants.rs proves its ruleset is production's rather than
assuming it. Rust cannot read the manifests (ADR-0001), so instead
starter_decks asserts every piece of ice in either starter deck registers
hooks — which makes the empty vanilla set and the real list agree on every
rez the file can reach. Named as the_starter_decks_are_played_under_productions_rez_rule;
the day a vanilla ice joins a starter deck it fails there, with the reason.
5. Ping through the rez paths. Ticket 64 already drove free_rez end to
end (card_30069_stolen_on_the_last_access_...), so this item was
*partially* covered on arrival — that work was not redone. What was still
hook-only is the other two live paths, now covered by
card_30055_rezzed_in_the_approach_window_tags_and_then_ends_the_run
(engine::rez_ice) and card_30055_rezzed_by_the_paid_ability_mid_run_still_tags
(engine::rez_card).
6. The retired ETR, through a real encounter. The same approach-window test
continues past the rez with Action::PassIce and asserts Ping's *registered*
"End the run." resolves and ends the run — and that the "no subroutines to
resolve" line is absent. Dropping .ice_subs(&["End the run."]) from
reg.card("30055") turns it red, which is the card-specific failure the
unregistered-code lookup test could not give.
7. The constructor guard is anchored to what the import bound.
_constructor_names resolves the module aliases and the `from ... import
Game [as X] names per file, follows one assignment hop (factory =
netrunner_engine.Game`), and searches for calls on those. The old literal
Game( pattern stays as a floor, so the guard only ever widened.
test_the_alias_resolver_reads_the_import_and_not_the_call pins the
resolver on its own.
8. vanilla_from_header splits into parse_vanilla_header (codes +
optional warning) so the warnings are testable, and now distinguishes
missing / not-a-list / non-string-entries, naming what it dropped. Four
tests, where there were none.
9. Decided: gate them. _has_decision takes the same decider rule as
_dec_data. Predictive Planogram is the case that settles it — the window
only opens on the untagged branch, so its existence reports which branch the
Corp's operation took. Nothing is lost: legal_actions is already
decider-gated, so a client told the window is open has nothing to render.
Pinned by test_a_payload_less_window_is_still_the_deciders_alone and its
reveal_hidden counterpart in tests/test_decision_privacy.py.