Summary
The AI's deadlock escape hatch emits an action the engine is guaranteed to reject for
exact-count selection windows. The "escape" is a permanent reject/retry cycle, so the game
hangs at the cleanup discard.
Reporter: "End of AI turn, stuck in a discard loop looking at the logs." Their in-game debug
console shows ERROR (200) with this line repeating:
[Debug] AI controller halted after 3 failed proposals on DiscardToHandSize
Root cause
crates/phase-ai/src/search.rs:1071-1085 — one match arm, prefaced
"Selection states: empty selection is a valid 'choose nothing'", returns
Some(GameAction::SelectCards { cards: Vec::new() }) for a list that includes
WaitingFor::DiscardToHandSize.
The engine rejects that unconditionally — crates/engine/src/game/engine_resolution_choices.rs:4093-4107:
(WaitingFor::DiscardToHandSize { player, count, cards },
GameAction::SelectCards { cards: chosen }) => {
if chosen.len() != count {
return Err(EngineError::InvalidAction(format!(
"Must discard exactly {} card(s), got {}", count, chosen.len())));
}
DiscardToHandSize has no up_to field (types/game_state.rs:9703-9711), so there is no
relaxation path. With count == 3, an empty selection fails in every state, forever.
This is a class defect
The same arm wrongly covers four more exact-count windows:
| WaitingFor |
Engine's actual rule |
DiscardToHandSize |
exact count, no up_to — engine_resolution_choices.rs:4101 |
ConniveDiscard |
exact count, no up_to — :4135 |
ChooseFromZoneChoice |
exact when up_to == false — :3800-3810 |
DiscardChoice / EffectZoneChoice |
exact when up_to == false — the arm ignores up_to |
The engine already publishes the authoritative grouping at ai_support/mod.rs:546-568
(ChooseFromZoneChoice | ConniveDiscard | DiscardToHandSize under
selection_mismatch(chosen, cards, Some(*count)) — i.e. exact). The fallback arm contradicts it.
The adjacent arms in the same match already special-case this exact hazard —
EffectZoneChoice{Sacrifice} (search.rs:1058-1069), BeholdChoice (:1088-1092),
SearchPartitionChoice (:1099-1113) each carry a comment that an empty selection is illegal.
These variants were simply missed.
The correct handler exists and is bypassed
search.rs:3436-3478 handles DiscardToHandSize properly — scores the hand with
card_value::cmp_keep and returns exactly count ids (CR-annotated 514.1 + 701.9a).
It is skipped by an early return in score_candidates, search.rs:2638-2661:
if actions.is_empty() { return vec![]; } // 2644-2646, returns FIRST
...
if let Some(action) = deterministic_choice(...) // 2657, never reached
A comment at search.rs:2593-2595 names this precise hazard for the combat case:
"This must run before validation/gating, which can filter out all candidates and cause an
empty-actions early return that skips deterministic_choice." The discard path never got the guard.
Then choose_action_with_session_inner (search.rs:288-300) falls through to fallback_action,
and root_action_is_allowed (:385-391) returns true unconditionally for anything that is not
CastSpell/ActivateAbility — so the empty SelectCards passes straight through.
Evidence from the attached capture
waiting_for = DiscardToHandSize { player: 1, count: 3, cards: [10 ids] }, phase Cleanup, turn 21
cards_discarded_this_turn_by_player == {} and players_who_discarded_card_this_turn == [] —
both empty. Zero discards executed. This rules out the "discard succeeds then re-prompts" family.
- The engine's offer is correct: all 64 legal actions are
SelectCards carrying exactly 3 ids,
all within the hand, all distinct (a capped enumeration of C(10,3)=120, per
ai_support/candidates.rs:1893-1897).
The non-null resolving_stack_entry (a Smothering Tithe trigger) is incidental — it is a
transient carrier documented at types/game_state.rs:14548-14563 as cleared at the start of the
next resolve_top, and all three Tithe triggers resolved (ability_resolutions_this_turn shows
"98_0": 3 against 3 draws).
Fix
Remove the exact-count variants from the "empty selection is fine" arm and route them to a
selection that satisfies the engine's own cardinality contract, deriving exact-vs-up_to from the
authority already published at ai_support/mod.rs:546-568 rather than a second hand-maintained
list. Separately, guard the actions.is_empty() early return so deterministic_choice still runs.
The halt itself is at client/src/game/controllers/aiController.ts:264-272.
Summary
The AI's deadlock escape hatch emits an action the engine is guaranteed to reject for
exact-count selection windows. The "escape" is a permanent reject/retry cycle, so the game
hangs at the cleanup discard.
Reporter: "End of AI turn, stuck in a discard loop looking at the logs." Their in-game debug
console shows ERROR (200) with this line repeating:
Root cause
crates/phase-ai/src/search.rs:1071-1085— one match arm, prefaced"Selection states: empty selection is a valid 'choose nothing'", returns
Some(GameAction::SelectCards { cards: Vec::new() })for a list that includesWaitingFor::DiscardToHandSize.The engine rejects that unconditionally —
crates/engine/src/game/engine_resolution_choices.rs:4093-4107:DiscardToHandSizehas noup_tofield (types/game_state.rs:9703-9711), so there is norelaxation path. With
count == 3, an empty selection fails in every state, forever.This is a class defect
The same arm wrongly covers four more exact-count windows:
DiscardToHandSizecount, noup_to—engine_resolution_choices.rs:4101ConniveDiscardcount, noup_to—:4135ChooseFromZoneChoiceup_to == false—:3800-3810DiscardChoice/EffectZoneChoiceup_to == false— the arm ignoresup_toThe engine already publishes the authoritative grouping at
ai_support/mod.rs:546-568(
ChooseFromZoneChoice | ConniveDiscard | DiscardToHandSizeunderselection_mismatch(chosen, cards, Some(*count))— i.e. exact). The fallback arm contradicts it.The adjacent arms in the same match already special-case this exact hazard —
EffectZoneChoice{Sacrifice}(search.rs:1058-1069),BeholdChoice(:1088-1092),SearchPartitionChoice(:1099-1113) each carry a comment that an empty selection is illegal.These variants were simply missed.
The correct handler exists and is bypassed
search.rs:3436-3478handlesDiscardToHandSizeproperly — scores the hand withcard_value::cmp_keepand returns exactlycountids (CR-annotated 514.1 + 701.9a).It is skipped by an early return in
score_candidates,search.rs:2638-2661:A comment at
search.rs:2593-2595names this precise hazard for the combat case:"This must run before validation/gating, which can filter out all candidates and cause an
empty-actions early return that skips deterministic_choice." The discard path never got the guard.
Then
choose_action_with_session_inner(search.rs:288-300) falls through tofallback_action,and
root_action_is_allowed(:385-391) returnstrueunconditionally for anything that is notCastSpell/ActivateAbility— so the emptySelectCardspasses straight through.Evidence from the attached capture
waiting_for = DiscardToHandSize { player: 1, count: 3, cards: [10 ids] }, phaseCleanup, turn 21cards_discarded_this_turn_by_player == {}andplayers_who_discarded_card_this_turn == []—both empty. Zero discards executed. This rules out the "discard succeeds then re-prompts" family.
SelectCardscarrying exactly 3 ids,all within the hand, all distinct (a capped enumeration of C(10,3)=120, per
ai_support/candidates.rs:1893-1897).The non-null
resolving_stack_entry(a Smothering Tithe trigger) is incidental — it is atransient carrier documented at
types/game_state.rs:14548-14563as cleared at the start of thenext
resolve_top, and all three Tithe triggers resolved (ability_resolutions_this_turnshows"98_0": 3against 3 draws).Fix
Remove the exact-count variants from the "empty selection is fine" arm and route them to a
selection that satisfies the engine's own cardinality contract, deriving exact-vs-
up_tofrom theauthority already published at
ai_support/mod.rs:546-568rather than a second hand-maintainedlist. Separately, guard the
actions.is_empty()early return sodeterministic_choicestill runs.The halt itself is at
client/src/game/controllers/aiController.ts:264-272.