Skip to content

AI softlock: fallback emits empty SelectCards for exact-count windows the engine always rejects (DiscardToHandSize + 4 siblings) #6942

Description

@matthewevans

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_toengine_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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions