Status: done (commit 53a8913, 2026-08-05)
Where this came from: the 2026-08-04 live-game stall (game bc3085ab,
human Corp vs AI Runner). The stall itself was a serializer bug —
server/serialize.py gated the human's legal_actions on active_side
instead of game.decider() — fixed that day, with regression tests in
tests/test_decision_privacy.py (test_a_cross_turn_decision_*). This
ticket is the follow-up the debugging surfaced: the engine ordering that
*manufactured* the game's only cross-turn decision in the first place.
Sericulture Expansion (35049): "When your discard phase ends, you may remove 1 hosted agenda counter to place 2 advancement counters on 1 installed card." The discard phase is the last phase of the Corp's turn, so by the rules that window opens and resolves inside the Corp's turn, before the Runner's turn begins — and before the Runner's turn-begins triggers.
What engine::pass_turn (rust/netrunner-core/src/engine.rs, the
active_side == Corp branch) actually does, in order:
1. discard down to hand size
2. hooks::trigger_discard_phase_end — pushes the Sericulture decision
(decider: Corp) onto the stack
3. active_side = Runner, phase = RunnerTurnStart, Runner gets 4 clicks,
log --- Runner's Turn ---
4. hooks::trigger_runner_turn_start — Smartware Distributor et al. fire
5. phase = RunnerTurn
So the Corp answers its own end-of-turn window as the first thing of the
Runner's turn, *after* the Runner's turn-begins triggers have resolved. The
shortcut is deliberate and documented (cards/elev.rs, the "Timing note" on
sericulture_discard_phase_end), on the argument that nothing between the
two moments reads or writes what the decision touches.
The shortcut is outcome-equivalent today (verified during the debug):
placing advancement counters;
no_score_turn)binds under neither ordering, because the engine has no post-discard scoring window.
But it costs:
that deadlocked the serializer (AI driver waits on decider(), human
polls an empty legal_actions). The serializer is fixed; the engine shape
that produced the trap remains.
and Smartware paying out before the Corp's end-of-turn prompt — reported as confusing by the user who hit it.
commuting with every future discard-phase-end decision. Nothing checks this; it just has to stay true.
Split the handover so the decision stack drains while it is still the Corp's turn:
1. New GamePhase::CorpTurnEnd.
2. In pass_turn's corp branch: discard down → trigger_discard_phase_end
→ if the decision stack is non-empty, set phase = CorpTurnEnd and
return, leaving active_side = Corp. If the stack is empty, complete
the handover as today.
3. Extract steps 3–5 above into fn begin_runner_turn(state). After a
decision resolver pops the stack (the shared point is
restore_priority / the post-resolution path in apply_action), if
phase == CorpTurnEnd and the stack is now empty, call
begin_runner_turn. Only when empty — several scored copies (plus
Jinteki: Restoring Humanity, which resolves inline and pushes nothing)
can stack multiple windows, resolved LIFO as trigger_discard_phase_end
already promises.
snapshot.rs — check
SNAPSHOT_SCHEMA / tests/test_wire_golden.py expectations.
new phase (legal-action preemption already handles the options
themselves). ai_invariants.rs seed claims (CHECKPOINT_SEED, the broad
sweep) may shift where a self-played game contains a Sericulture window;
the bounds are loose on purpose — re-validate, don't pin.
elev.rs "Timing note" and the
sericulture_target doc line about "answered first thing in the Runner's
turn"; re-check the no_score_turn stamp comment (semantics unchanged —
it still never binds until a discard-phase scoring window exists).
sericulture_window fixture in
tests/test_decision_privacy.py stops on
pending && active_side == "runner" — after this change the window lives
at active_side == "corp", so the stop condition and the two tests'
cross-turn framing must be updated *without losing what they pin*: the
decider gate in serialize.py. Keep a decider ≠ active-side state under
test — a run's corp windows (rez, Byte!) still provide one naturally.
in normal turn order; confirm the Runner-side client shows a sensible waiting state rather than an actionable board.
1. While a Sericulture window is open: active_side == "corp",
phase == "corp_turn_end", the Runner has 0 clicks, and no runner
turn-begins trigger has fired.
2. Log order: the Sericulture prompt and its resolution precede
--- Runner's Turn ---.
3. Two scored copies stack two windows; both resolve (in LIFO order), then
exactly one handover happens.
4. make test green, including the updated decision-privacy tests and
re-validated ai_invariants seed claims.