weiss_core/
legal.rs

1use serde::{Deserialize, Serialize};
2use smallvec::SmallVec;
3use std::collections::HashSet;
4
5use crate::config::CurriculumConfig;
6use crate::db::{AbilityCost, CardDb, CardType};
7use crate::encode::{
8    ACTION_SPACE_SIZE, ATTACK_BASE, CHOICE_BASE, CHOICE_COUNT, CHOICE_NEXT_ID, CHOICE_PREV_ID,
9    CLIMAX_PLAY_BASE, CLOCK_HAND_BASE, CONCEDE_ID, ENCORE_DECLINE_BASE, ENCORE_PAY_BASE,
10    LEVEL_UP_BASE, MAIN_MOVE_BASE, MAIN_PLAY_CHAR_BASE, MAIN_PLAY_EVENT_BASE, MULLIGAN_CONFIRM_ID,
11    MULLIGAN_SELECT_BASE, PASS_ACTION_ID, TRIGGER_ORDER_BASE,
12};
13use crate::modifier_queries::{collect_attack_slot_state, modifier_targets_slot_card};
14use crate::state::{AttackType, CardInstance, GameState, ModifierKind, StageSlot, StageStatus};
15
16use self::hand_play_requirements::{card_set_allowed, meets_play_requirements};
17
18/// Shared play-requirement predicates used by legality and observation reasons.
19pub(crate) mod hand_play_requirements;
20
21const MAX_HAND: usize = crate::encode::MAX_HAND;
22const MAX_STAGE: usize = 5;
23
24#[derive(Clone, Copy)]
25struct StageModifierCache {
26    cannot_play_events_from_hand: bool,
27    cannot_move_stage_position: [bool; MAX_STAGE],
28    encore_stock_cost_min: [Option<usize>; MAX_STAGE],
29}
30
31impl StageModifierCache {
32    #[inline(always)]
33    fn build(state: &GameState, player: usize) -> Self {
34        let mut cache = Self {
35            cannot_play_events_from_hand: false,
36            cannot_move_stage_position: [false; MAX_STAGE],
37            encore_stock_cost_min: [None; MAX_STAGE],
38        };
39        if state.modifiers.is_empty() {
40            return cache;
41        }
42        let stage = &state.players[player].stage;
43        let stage_len = stage.len().min(MAX_STAGE);
44        let mut slot_card_ids = [0u32; MAX_STAGE];
45        for (slot, slot_state) in stage.iter().take(stage_len).enumerate() {
46            slot_card_ids[slot] = slot_state.card.map(|c| c.id).unwrap_or(0);
47        }
48        for modifier in &state.modifiers {
49            if modifier.magnitude == 0 {
50                continue;
51            }
52            let slot = modifier.target_slot as usize;
53            if slot >= stage_len {
54                continue;
55            }
56            let card_id = slot_card_ids[slot];
57            if card_id == 0 || !modifier_targets_slot_card(modifier, player, slot, card_id) {
58                continue;
59            }
60            match modifier.kind {
61                ModifierKind::CannotPlayEventsFromHand => {
62                    cache.cannot_play_events_from_hand = true;
63                }
64                ModifierKind::CannotMoveStagePosition => {
65                    cache.cannot_move_stage_position[slot] = true;
66                }
67                ModifierKind::EncoreStockCost if modifier.magnitude > 0 => {
68                    let cost = modifier.magnitude as usize;
69                    let entry = &mut cache.encore_stock_cost_min[slot];
70                    *entry = Some(match *entry {
71                        Some(existing) => existing.min(cost),
72                        None => cost,
73                    });
74                }
75                _ => {}
76            }
77        }
78        cache
79    }
80}
81
82/// Player decision kinds exposed to callers.
83#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
84pub enum DecisionKind {
85    /// Mulligan decision (select cards to discard).
86    Mulligan,
87    /// Clock phase decision.
88    Clock,
89    /// Main phase decision.
90    Main,
91    /// Climax phase decision.
92    Climax,
93    /// Attack declaration decision.
94    AttackDeclaration,
95    /// Level-up choice decision.
96    LevelUp,
97    /// Encore step decision.
98    Encore,
99    /// Trigger order decision.
100    TriggerOrder,
101    /// Choice selection decision.
102    Choice,
103}
104
105/// A pending decision describing which player must act next.
106#[derive(Clone, Debug, Serialize, Deserialize)]
107pub struct Decision {
108    /// Player index who must act.
109    pub player: u8,
110    /// Decision kind.
111    pub kind: DecisionKind,
112    /// Optional focus slot for contextual decisions.
113    pub focus_slot: Option<u8>,
114}
115
116/// Canonical action descriptor used as the truth representation of legal actions.
117#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
118pub enum ActionDesc {
119    /// Confirm mulligan without selecting additional cards.
120    MulliganConfirm,
121    /// Select a hand card for mulligan.
122    MulliganSelect {
123        /// Zero-based hand index parameter.
124        hand_index: u8,
125    },
126    /// Pass the current decision.
127    Pass,
128    /// Clock a hand card.
129    Clock {
130        /// Zero-based hand index parameter.
131        hand_index: u8,
132    },
133    /// Play a character from hand to a stage slot.
134    MainPlayCharacter {
135        /// Zero-based hand index parameter.
136        hand_index: u8,
137        /// Zero-based stage slot parameter.
138        stage_slot: u8,
139    },
140    /// Play an event from hand.
141    MainPlayEvent {
142        /// Zero-based hand index parameter.
143        hand_index: u8,
144    },
145    /// Move a character between stage slots.
146    MainMove {
147        /// Zero-based source stage slot parameter.
148        from_slot: u8,
149        /// Zero-based destination stage slot parameter.
150        to_slot: u8,
151    },
152    /// Activate a character ability from a stage slot.
153    MainActivateAbility {
154        /// Zero-based stage slot parameter.
155        slot: u8,
156        /// Zero-based ability index on the source card.
157        ability_index: u8,
158    },
159    /// Play a climax from hand.
160    ClimaxPlay {
161        /// Zero-based hand index parameter.
162        hand_index: u8,
163    },
164    /// Declare an attack from a stage slot.
165    Attack {
166        /// Zero-based stage slot parameter.
167        slot: u8,
168        /// Attack type parameter.
169        attack_type: AttackType,
170    },
171    /// Play a counter from hand.
172    CounterPlay {
173        /// Zero-based hand index parameter.
174        hand_index: u8,
175    },
176    /// Select a card for level up.
177    LevelUp {
178        /// Zero-based selection index parameter.
179        index: u8,
180    },
181    /// Pay encore for a character.
182    EncorePay {
183        /// Zero-based stage slot parameter.
184        slot: u8,
185    },
186    /// Decline encore for a character.
187    EncoreDecline {
188        /// Zero-based stage slot parameter.
189        slot: u8,
190    },
191    /// Select trigger order index.
192    TriggerOrder {
193        /// Zero-based selection index parameter.
194        index: u8,
195    },
196    /// Select a choice option by index.
197    ChoiceSelect {
198        /// Zero-based selection index parameter.
199        index: u8,
200    },
201    /// Page to previous choice options.
202    ChoicePrevPage,
203    /// Page to next choice options.
204    ChoiceNextPage,
205    /// Concede the game.
206    Concede,
207}
208
209/// Compact list of canonical legal actions.
210pub type LegalActions = SmallVec<[ActionDesc; 64]>;
211/// Compact list of legal action ids.
212pub type LegalActionIds = SmallVec<[u16; 64]>;
213
214#[inline(always)]
215fn push_id(out: &mut LegalActionIds, id: usize) {
216    debug_assert!(ACTION_SPACE_SIZE <= u16::MAX as usize);
217    debug_assert!(id < ACTION_SPACE_SIZE);
218    out.push(id as u16);
219}
220
221#[inline(always)]
222fn attack_type_to_index(attack_type: AttackType) -> usize {
223    match attack_type {
224        AttackType::Frontal => 0,
225        AttackType::Side => 1,
226        AttackType::Direct => 2,
227    }
228}
229
230#[inline(always)]
231fn starting_player_first_turn(state: &GameState, player: u8) -> bool {
232    state.turn.turn_number == 0 && player == state.turn.starting_player
233}
234
235#[inline(always)]
236fn starting_player_first_turn_attack_used(state: &GameState, player: u8) -> bool {
237    if !starting_player_first_turn(state, player) {
238        return false;
239    }
240    state.turn.attack_subphase_count > 0
241}
242
243#[inline(always)]
244fn can_pay_cost_from_state(
245    state: &GameState,
246    player: usize,
247    slot: usize,
248    source: CardInstance,
249    cost: AbilityCost,
250    enforce_cost_requirement: bool,
251) -> bool {
252    if cost.rest_self {
253        if slot >= state.players[player].stage.len() {
254            return false;
255        }
256        let slot_state = &state.players[player].stage[slot];
257        if slot_state.card.map(|c| c.instance_id) != Some(source.instance_id) {
258            return false;
259        }
260        if slot_state.status != StageStatus::Stand {
261            return false;
262        }
263    }
264    if cost.rest_other > 0 {
265        let mut available = 0usize;
266        for (idx, slot_state) in state.players[player].stage.iter().enumerate() {
267            if idx == slot {
268                continue;
269            }
270            if slot_state.card.is_some() && slot_state.status == StageStatus::Stand {
271                available += 1;
272            }
273        }
274        if available < cost.rest_other as usize {
275            return false;
276        }
277    }
278    if cost.stock > 0
279        && enforce_cost_requirement
280        && state.players[player].stock.len() < cost.stock as usize
281    {
282        return false;
283    }
284    let required_hand = cost.discard_from_hand as usize
285        + cost.clock_from_hand as usize
286        + cost.reveal_from_hand as usize;
287    if required_hand > state.players[player].hand.len() {
288        return false;
289    }
290    if cost.clock_from_deck_top > 0
291        && state.players[player].deck.len() < cost.clock_from_deck_top as usize
292    {
293        return false;
294    }
295    true
296}
297
298#[inline(always)]
299fn can_pay_encore_for_slot(
300    state: &GameState,
301    db: &CardDb,
302    curriculum: &CurriculumConfig,
303    player: usize,
304    slot: usize,
305    modifier_cache: Option<&StageModifierCache>,
306) -> bool {
307    if state.turn.cannot_use_auto_encore[player] {
308        return false;
309    }
310    if slot >= state.players[player].stage.len() {
311        return false;
312    }
313    let Some(card_inst) = state.players[player].stage[slot].card else {
314        return false;
315    };
316    let stock_len = state.players[player].stock.len();
317    let mut min_stock_cost = if stock_len >= 3 { Some(3usize) } else { None };
318    if let Some(cache) = modifier_cache {
319        if let Some(cost) = cache
320            .encore_stock_cost_min
321            .get(slot)
322            .and_then(|entry| *entry)
323        {
324            min_stock_cost = Some(match min_stock_cost {
325                Some(existing) => existing.min(cost),
326                None => cost,
327            });
328        }
329    } else {
330        for modifier in &state.modifiers {
331            if modifier.kind != ModifierKind::EncoreStockCost || modifier.magnitude <= 0 {
332                continue;
333            }
334            if !modifier_targets_slot_card(modifier, player, slot, card_inst.id) {
335                continue;
336            }
337            let cost = modifier.magnitude as usize;
338            min_stock_cost = Some(match min_stock_cost {
339                Some(existing) => existing.min(cost),
340                None => cost,
341            });
342        }
343    }
344    if let Some(cost) = min_stock_cost {
345        if stock_len >= cost {
346            return true;
347        }
348    }
349    db.iter_card_abilities_in_canonical_order(card_inst.id)
350        .iter()
351        .filter_map(|spec| spec.template.encore_variant_cost())
352        .any(|cost| {
353            can_pay_cost_from_state(
354                state,
355                player,
356                slot,
357                card_inst,
358                cost,
359                curriculum.enforce_cost_requirement,
360            )
361        })
362}
363
364#[derive(Clone, Copy)]
365enum PlayableHandCard {
366    MainCharacter { hand_index: usize },
367    MainEvent { hand_index: usize },
368    Climax { hand_index: usize },
369}
370
371#[derive(Clone, Copy, PartialEq, Eq)]
372enum HandScanMode {
373    Main,
374    Climax,
375}
376
377#[inline(always)]
378fn for_each_playable_hand_card<F>(
379    player: &crate::state::PlayerState,
380    db: &CardDb,
381    curriculum: &CurriculumConfig,
382    allowed_card_sets: Option<&HashSet<String>>,
383    mode: HandScanMode,
384    events_locked: bool,
385    mut visit: F,
386) where
387    F: FnMut(PlayableHandCard),
388{
389    let can_play_climax =
390        curriculum.enable_climax_phase && curriculum.allow_climax && player.climax.is_empty();
391    if mode == HandScanMode::Climax && !can_play_climax {
392        return;
393    }
394
395    for (hand_index, card_inst) in player.hand.iter().enumerate() {
396        if hand_index >= MAX_HAND || hand_index > u8::MAX as usize {
397            break;
398        }
399        let Some(card) = db.get(card_inst.id) else {
400            continue;
401        };
402        if !card_set_allowed(card, curriculum, allowed_card_sets) {
403            continue;
404        }
405        match mode {
406            HandScanMode::Main => match card.card_type {
407                CardType::Character => {
408                    if curriculum.allow_character
409                        && meets_play_requirements(card, player, db, curriculum, 0, false)
410                    {
411                        visit(PlayableHandCard::MainCharacter { hand_index });
412                    }
413                }
414                CardType::Event => {
415                    if !events_locked
416                        && curriculum.allow_event
417                        && meets_play_requirements(card, player, db, curriculum, 0, false)
418                    {
419                        visit(PlayableHandCard::MainEvent { hand_index });
420                    }
421                }
422                CardType::Climax => {}
423            },
424            HandScanMode::Climax => {
425                if card.card_type == CardType::Climax
426                    && meets_play_requirements(card, player, db, curriculum, 0, false)
427                {
428                    visit(PlayableHandCard::Climax { hand_index });
429                }
430            }
431        }
432    }
433}
434
435#[inline(always)]
436fn append_mulligan_action_ids(state: &GameState, player: usize, out: &mut LegalActionIds) {
437    push_id(out, MULLIGAN_CONFIRM_ID);
438    let p = &state.players[player];
439    for (hand_index, _) in p.hand.iter().enumerate() {
440        if hand_index >= MAX_HAND || hand_index > u8::MAX as usize {
441            break;
442        }
443        push_id(out, MULLIGAN_SELECT_BASE + hand_index);
444    }
445}
446
447#[inline(always)]
448fn append_clock_action_ids(
449    state: &GameState,
450    player: usize,
451    db: &CardDb,
452    curriculum: &CurriculumConfig,
453    allowed_card_sets: Option<&HashSet<String>>,
454    out: &mut LegalActionIds,
455) {
456    push_id(out, PASS_ACTION_ID);
457    let p = &state.players[player];
458    for (hand_index, card_inst) in p.hand.iter().enumerate() {
459        if hand_index >= MAX_HAND || hand_index > u8::MAX as usize {
460            break;
461        }
462        if let Some(card) = db.get(card_inst.id) {
463            if !card_set_allowed(card, curriculum, allowed_card_sets) {
464                continue;
465            }
466            push_id(out, CLOCK_HAND_BASE + hand_index);
467        }
468    }
469}
470
471#[inline(always)]
472fn append_main_action_ids(
473    state: &GameState,
474    player: usize,
475    db: &CardDb,
476    curriculum: &CurriculumConfig,
477    allowed_card_sets: Option<&HashSet<String>>,
478    out: &mut LegalActionIds,
479) {
480    let p = &state.players[player];
481    let modifier_cache = StageModifierCache::build(state, player);
482    let max_slot = if curriculum.reduced_stage_mode {
483        1
484    } else {
485        MAX_STAGE
486    };
487    let events_locked = modifier_cache.cannot_play_events_from_hand;
488    push_id(out, PASS_ACTION_ID);
489    for_each_playable_hand_card(
490        p,
491        db,
492        curriculum,
493        allowed_card_sets,
494        HandScanMode::Main,
495        events_locked,
496        |playable| match playable {
497            PlayableHandCard::MainCharacter { hand_index } => {
498                for slot in 0..max_slot {
499                    let id = MAIN_PLAY_CHAR_BASE + hand_index * MAX_STAGE + slot;
500                    push_id(out, id);
501                }
502            }
503            PlayableHandCard::MainEvent { hand_index } => {
504                push_id(out, MAIN_PLAY_EVENT_BASE + hand_index);
505            }
506            PlayableHandCard::Climax { .. } => {}
507        },
508    );
509    for from in 0..max_slot {
510        for to in 0..max_slot {
511            if from == to {
512                continue;
513            }
514            let from_slot = &p.stage[from];
515            let to_slot = &p.stage[to];
516            if from_slot.card.is_some()
517                && is_character_slot(from_slot, db)
518                && (to_slot.card.is_none() || is_character_slot(to_slot, db))
519                && !modifier_cache.cannot_move_stage_position[from]
520                && (to_slot.card.is_none() || !modifier_cache.cannot_move_stage_position[to])
521            {
522                let to_index = if to < from { to } else { to - 1 };
523                let id = MAIN_MOVE_BASE + from * (MAX_STAGE - 1) + to_index;
524                push_id(out, id);
525            }
526        }
527    }
528}
529
530#[inline(always)]
531fn append_climax_action_ids(
532    state: &GameState,
533    player: usize,
534    db: &CardDb,
535    curriculum: &CurriculumConfig,
536    allowed_card_sets: Option<&HashSet<String>>,
537    out: &mut LegalActionIds,
538) {
539    let p = &state.players[player];
540    push_id(out, PASS_ACTION_ID);
541    for_each_playable_hand_card(
542        p,
543        db,
544        curriculum,
545        allowed_card_sets,
546        HandScanMode::Climax,
547        false,
548        |playable| {
549            if let PlayableHandCard::Climax { hand_index } = playable {
550                push_id(out, CLIMAX_PLAY_BASE + hand_index);
551            }
552        },
553    );
554}
555
556#[inline(always)]
557fn append_attack_declaration_action_ids(
558    state: &GameState,
559    player: u8,
560    curriculum: &CurriculumConfig,
561    out: &mut LegalActionIds,
562) {
563    push_id(out, PASS_ACTION_ID);
564    if starting_player_first_turn_attack_used(state, player) {
565        return;
566    }
567    let max_slot = if curriculum.reduced_stage_mode { 1 } else { 3 };
568    for slot in 0..max_slot {
569        let slot_u8 = slot as u8;
570        for attack_type in [AttackType::Frontal, AttackType::Side, AttackType::Direct] {
571            if can_declare_attack(state, player, slot_u8, attack_type, curriculum).is_ok() {
572                let id = ATTACK_BASE + slot * 3 + attack_type_to_index(attack_type);
573                push_id(out, id);
574            }
575        }
576    }
577}
578
579#[inline(always)]
580fn append_level_up_action_ids(state: &GameState, player: usize, out: &mut LegalActionIds) {
581    if state.players[player].clock.len() >= 7 {
582        for idx in 0..7 {
583            push_id(out, LEVEL_UP_BASE + idx);
584        }
585    }
586}
587
588#[inline(always)]
589fn append_encore_action_ids(
590    state: &GameState,
591    player: usize,
592    db: &CardDb,
593    curriculum: &CurriculumConfig,
594    out: &mut LegalActionIds,
595) {
596    let p = &state.players[player];
597    let modifier_cache = StageModifierCache::build(state, player);
598    for slot in 0..p.stage.len() {
599        if p.stage[slot].card.is_some()
600            && p.stage[slot].status == StageStatus::Reverse
601            && can_pay_encore_for_slot(state, db, curriculum, player, slot, Some(&modifier_cache))
602        {
603            push_id(out, ENCORE_PAY_BASE + slot);
604        }
605    }
606    for slot in 0..p.stage.len() {
607        if p.stage[slot].card.is_some() && p.stage[slot].status == StageStatus::Reverse {
608            push_id(out, ENCORE_DECLINE_BASE + slot);
609        }
610    }
611}
612
613#[inline(always)]
614fn append_trigger_order_action_ids(state: &GameState, out: &mut LegalActionIds) {
615    let choices = state
616        .turn
617        .trigger_order
618        .as_ref()
619        .map(|o| o.choices.len())
620        .unwrap_or(0);
621    let max = choices.min(10);
622    for idx in 0..max {
623        push_id(out, TRIGGER_ORDER_BASE + idx);
624    }
625}
626
627#[inline(always)]
628fn append_choice_action_ids(state: &GameState, out: &mut LegalActionIds) {
629    if let Some(choice) = state.turn.choice.as_ref() {
630        let total = choice.total_candidates as usize;
631        let page_start = choice.page_start as usize;
632        let safe_start = page_start.min(total);
633        let page_end = total.min(safe_start + CHOICE_COUNT);
634        for idx in 0..(page_end - safe_start) {
635            push_id(out, CHOICE_BASE + idx);
636        }
637        if page_start >= CHOICE_COUNT {
638            push_id(out, CHOICE_PREV_ID);
639        }
640        if page_start + CHOICE_COUNT < total {
641            push_id(out, CHOICE_NEXT_ID);
642        }
643    }
644}
645
646#[inline(always)]
647fn append_mulligan_actions(state: &GameState, player: usize, actions: &mut LegalActions) {
648    let p = &state.players[player];
649    actions.push(ActionDesc::MulliganConfirm);
650    for (hand_index, _) in p.hand.iter().enumerate() {
651        if hand_index >= MAX_HAND || hand_index > u8::MAX as usize {
652            break;
653        }
654        actions.push(ActionDesc::MulliganSelect {
655            hand_index: hand_index as u8,
656        });
657    }
658}
659
660#[inline(always)]
661fn append_clock_actions(
662    state: &GameState,
663    player: usize,
664    db: &CardDb,
665    curriculum: &CurriculumConfig,
666    allowed_card_sets: Option<&HashSet<String>>,
667    actions: &mut LegalActions,
668) {
669    actions.push(ActionDesc::Pass);
670    let p = &state.players[player];
671    for (hand_index, card_inst) in p.hand.iter().enumerate() {
672        if hand_index >= MAX_HAND || hand_index > u8::MAX as usize {
673            break;
674        }
675        if let Some(card) = db.get(card_inst.id) {
676            if !card_set_allowed(card, curriculum, allowed_card_sets) {
677                continue;
678            }
679            actions.push(ActionDesc::Clock {
680                hand_index: hand_index as u8,
681            });
682        }
683    }
684}
685
686#[inline(always)]
687fn append_main_actions(
688    state: &GameState,
689    player: usize,
690    db: &CardDb,
691    curriculum: &CurriculumConfig,
692    allowed_card_sets: Option<&HashSet<String>>,
693    actions: &mut LegalActions,
694) {
695    let p = &state.players[player];
696    let modifier_cache = StageModifierCache::build(state, player);
697    let max_slot = if curriculum.reduced_stage_mode {
698        1
699    } else {
700        MAX_STAGE
701    };
702    let events_locked = modifier_cache.cannot_play_events_from_hand;
703    actions.push(ActionDesc::Pass);
704    for_each_playable_hand_card(
705        p,
706        db,
707        curriculum,
708        allowed_card_sets,
709        HandScanMode::Main,
710        events_locked,
711        |playable| match playable {
712            PlayableHandCard::MainCharacter { hand_index } => {
713                for slot in 0..max_slot {
714                    actions.push(ActionDesc::MainPlayCharacter {
715                        hand_index: hand_index as u8,
716                        stage_slot: slot as u8,
717                    });
718                }
719            }
720            PlayableHandCard::MainEvent { hand_index } => {
721                actions.push(ActionDesc::MainPlayEvent {
722                    hand_index: hand_index as u8,
723                });
724            }
725            PlayableHandCard::Climax { .. } => {}
726        },
727    );
728    for from in 0..max_slot {
729        for to in 0..max_slot {
730            if from == to {
731                continue;
732            }
733            let from_slot = &p.stage[from];
734            let to_slot = &p.stage[to];
735            if from_slot.card.is_some()
736                && is_character_slot(from_slot, db)
737                && (to_slot.card.is_none() || is_character_slot(to_slot, db))
738                && !modifier_cache.cannot_move_stage_position[from]
739                && (to_slot.card.is_none() || !modifier_cache.cannot_move_stage_position[to])
740            {
741                actions.push(ActionDesc::MainMove {
742                    from_slot: from as u8,
743                    to_slot: to as u8,
744                });
745            }
746        }
747    }
748}
749
750#[inline(always)]
751fn append_climax_actions(
752    state: &GameState,
753    player: usize,
754    db: &CardDb,
755    curriculum: &CurriculumConfig,
756    allowed_card_sets: Option<&HashSet<String>>,
757    actions: &mut LegalActions,
758) {
759    let p = &state.players[player];
760    actions.push(ActionDesc::Pass);
761    for_each_playable_hand_card(
762        p,
763        db,
764        curriculum,
765        allowed_card_sets,
766        HandScanMode::Climax,
767        false,
768        |playable| {
769            if let PlayableHandCard::Climax { hand_index } = playable {
770                actions.push(ActionDesc::ClimaxPlay {
771                    hand_index: hand_index as u8,
772                });
773            }
774        },
775    );
776}
777
778#[inline(always)]
779fn append_attack_declaration_actions(
780    state: &GameState,
781    player: u8,
782    curriculum: &CurriculumConfig,
783    actions: &mut LegalActions,
784) {
785    actions.push(ActionDesc::Pass);
786    legal_attack_actions_into(state, player, curriculum, actions);
787}
788
789#[inline(always)]
790fn append_level_up_actions(state: &GameState, player: usize, actions: &mut LegalActions) {
791    if state.players[player].clock.len() >= 7 {
792        for idx in 0..7 {
793            actions.push(ActionDesc::LevelUp { index: idx as u8 });
794        }
795    }
796}
797
798#[inline(always)]
799fn append_encore_actions(
800    state: &GameState,
801    player: usize,
802    db: &CardDb,
803    curriculum: &CurriculumConfig,
804    actions: &mut LegalActions,
805) {
806    let p = &state.players[player];
807    let modifier_cache = StageModifierCache::build(state, player);
808    for slot in 0..p.stage.len() {
809        if p.stage[slot].card.is_some()
810            && p.stage[slot].status == StageStatus::Reverse
811            && can_pay_encore_for_slot(state, db, curriculum, player, slot, Some(&modifier_cache))
812        {
813            actions.push(ActionDesc::EncorePay { slot: slot as u8 });
814        }
815    }
816    for slot in 0..p.stage.len() {
817        if p.stage[slot].card.is_some() && p.stage[slot].status == StageStatus::Reverse {
818            actions.push(ActionDesc::EncoreDecline { slot: slot as u8 });
819        }
820    }
821}
822
823#[inline(always)]
824fn append_trigger_order_actions(state: &GameState, actions: &mut LegalActions) {
825    let choices = state
826        .turn
827        .trigger_order
828        .as_ref()
829        .map(|o| o.choices.len())
830        .unwrap_or(0);
831    let max = choices.min(10);
832    for idx in 0..max {
833        actions.push(ActionDesc::TriggerOrder { index: idx as u8 });
834    }
835}
836
837#[inline(always)]
838fn append_choice_actions(state: &GameState, actions: &mut LegalActions) {
839    if let Some(choice) = state.turn.choice.as_ref() {
840        let total = choice.total_candidates as usize;
841        let page_start = choice.page_start as usize;
842        let safe_start = page_start.min(total);
843        let page_end = total.min(safe_start + CHOICE_COUNT);
844        for idx in 0..(page_end - safe_start) {
845            actions.push(ActionDesc::ChoiceSelect { index: idx as u8 });
846        }
847        if page_start >= CHOICE_COUNT {
848            actions.push(ActionDesc::ChoicePrevPage);
849        }
850        if page_start + CHOICE_COUNT < total {
851            actions.push(ActionDesc::ChoiceNextPage);
852        }
853    }
854}
855
856/// Compute legal action ids for a decision into a reusable buffer.
857#[inline(always)]
858pub fn legal_action_ids_cached_into(
859    state: &GameState,
860    decision: &Decision,
861    db: &CardDb,
862    curriculum: &CurriculumConfig,
863    allowed_card_sets: Option<&HashSet<String>>,
864    out: &mut LegalActionIds,
865) {
866    // Invariants:
867    // - Preserve canonical legal action ordering and action-id packing per decision.
868    // - Keep descriptor/id parity covered by `weiss_core/tests/legal_cache_parity_tests.rs`.
869    let player = decision.player as usize;
870    out.clear();
871    match decision.kind {
872        DecisionKind::Mulligan => append_mulligan_action_ids(state, player, out),
873        DecisionKind::Clock => {
874            append_clock_action_ids(state, player, db, curriculum, allowed_card_sets, out)
875        }
876        DecisionKind::Main => {
877            append_main_action_ids(state, player, db, curriculum, allowed_card_sets, out)
878        }
879        DecisionKind::Climax => {
880            append_climax_action_ids(state, player, db, curriculum, allowed_card_sets, out)
881        }
882        DecisionKind::AttackDeclaration => {
883            append_attack_declaration_action_ids(state, decision.player, curriculum, out)
884        }
885        DecisionKind::LevelUp => append_level_up_action_ids(state, player, out),
886        DecisionKind::Encore => append_encore_action_ids(state, player, db, curriculum, out),
887        DecisionKind::TriggerOrder => append_trigger_order_action_ids(state, out),
888        DecisionKind::Choice => append_choice_action_ids(state, out),
889    }
890    if curriculum.allow_concede {
891        push_id(out, CONCEDE_ID);
892    }
893}
894
895/// Validate whether an attack can be declared from a slot.
896pub fn can_declare_attack(
897    state: &GameState,
898    player: u8,
899    slot: u8,
900    attack_type: AttackType,
901    curriculum: &CurriculumConfig,
902) -> Result<(), &'static str> {
903    let p = player as usize;
904    let s = slot as usize;
905    if s >= MAX_STAGE || (curriculum.reduced_stage_mode && s > 0) {
906        return Err("Attack slot out of range");
907    }
908    if s >= 3 {
909        return Err("Attack must be from center stage");
910    }
911    let attacker_slot = &state.players[p].stage[s];
912    if attacker_slot.card.is_none() {
913        return Err("No attacker in slot");
914    }
915    if attacker_slot.status != StageStatus::Stand {
916        return Err("Attacker is rested");
917    }
918    if attacker_slot.has_attacked {
919        return Err("Attacker already attacked");
920    }
921    if starting_player_first_turn_attack_used(state, player) {
922        return Err("Starting player can only attack once on first turn");
923    }
924    let (cannot_attack, cannot_side_attack, cannot_frontal_attack, attack_cost) =
925        if let Some(derived) = state.turn.derived_attack.as_ref() {
926            let entry = derived.per_player[p][s];
927            (
928                entry.cannot_attack,
929                entry.cannot_side_attack,
930                entry.cannot_frontal_attack,
931                entry.attack_cost,
932            )
933        } else if let Some(card_inst) = attacker_slot.card {
934            collect_attack_slot_state(
935                state,
936                p,
937                s,
938                card_inst.id,
939                attacker_slot.cannot_attack,
940                attacker_slot.attack_cost,
941            )
942        } else {
943            (
944                attacker_slot.cannot_attack,
945                false,
946                false,
947                attacker_slot.attack_cost,
948            )
949        };
950    if cannot_attack {
951        return Err("Attacker cannot attack");
952    }
953    if attack_cost as usize > state.players[p].stock.len() {
954        return Err("Attack cost not payable");
955    }
956    let defender_player = 1 - p;
957    let defender_present = state.players[defender_player].stage[s].card.is_some();
958    match attack_type {
959        AttackType::Frontal | AttackType::Side if !defender_present => {
960            return Err("No defender for frontal/side attack");
961        }
962        AttackType::Frontal if cannot_frontal_attack => {
963            return Err("Attacker cannot frontal attack");
964        }
965        AttackType::Side if cannot_side_attack => {
966            return Err("Attacker cannot side attack");
967        }
968        AttackType::Direct if defender_present => {
969            return Err("Direct attack requires empty opposing slot");
970        }
971        AttackType::Side if !curriculum.enable_side_attacks => {
972            return Err("Side attacks disabled");
973        }
974        AttackType::Direct if !curriculum.enable_direct_attacks => {
975            return Err("Direct attacks disabled");
976        }
977        _ => {}
978    }
979    Ok(())
980}
981
982/// Compute legal attack actions into a reusable buffer.
983#[inline(always)]
984pub fn legal_attack_actions_into(
985    state: &GameState,
986    player: u8,
987    curriculum: &CurriculumConfig,
988    actions: &mut LegalActions,
989) {
990    if starting_player_first_turn_attack_used(state, player) {
991        return;
992    }
993    let max_slot = if curriculum.reduced_stage_mode { 1 } else { 3 };
994    for slot in 0..max_slot {
995        let slot_u8 = slot as u8;
996        for attack_type in [AttackType::Frontal, AttackType::Side, AttackType::Direct] {
997            if can_declare_attack(state, player, slot_u8, attack_type, curriculum).is_ok() {
998                actions.push(ActionDesc::Attack {
999                    slot: slot_u8,
1000                    attack_type,
1001                });
1002            }
1003        }
1004    }
1005}
1006
1007/// Compute legal attack actions for a player.
1008#[inline(always)]
1009pub fn legal_attack_actions(
1010    state: &GameState,
1011    player: u8,
1012    curriculum: &CurriculumConfig,
1013) -> LegalActions {
1014    let mut actions = LegalActions::new();
1015    legal_attack_actions_into(state, player, curriculum, &mut actions);
1016    actions
1017}
1018
1019/// Compute legal actions for a decision.
1020#[inline(always)]
1021pub fn legal_actions(
1022    state: &GameState,
1023    decision: &Decision,
1024    db: &CardDb,
1025    curriculum: &CurriculumConfig,
1026) -> LegalActions {
1027    legal_actions_cached(state, decision, db, curriculum, None)
1028}
1029
1030/// Compute legal actions using cached data structures where possible.
1031#[inline(always)]
1032pub fn legal_actions_cached(
1033    state: &GameState,
1034    decision: &Decision,
1035    db: &CardDb,
1036    curriculum: &CurriculumConfig,
1037    allowed_card_sets: Option<&HashSet<String>>,
1038) -> LegalActions {
1039    let mut actions = LegalActions::new();
1040    legal_actions_cached_into(
1041        state,
1042        decision,
1043        db,
1044        curriculum,
1045        allowed_card_sets,
1046        &mut actions,
1047    );
1048    actions
1049}
1050
1051/// Compute legal actions into a reusable buffer using cached data.
1052#[inline(always)]
1053pub fn legal_actions_cached_into(
1054    state: &GameState,
1055    decision: &Decision,
1056    db: &CardDb,
1057    curriculum: &CurriculumConfig,
1058    allowed_card_sets: Option<&HashSet<String>>,
1059    actions: &mut LegalActions,
1060) {
1061    // Invariants:
1062    // - Preserve canonical legal action ordering so `action_id_for` stays parity-aligned.
1063    // - Ordering/id parity is covered by `weiss_core/tests/legal_cache_parity_tests.rs`.
1064    let player = decision.player as usize;
1065    actions.clear();
1066    match decision.kind {
1067        DecisionKind::Mulligan => append_mulligan_actions(state, player, actions),
1068        DecisionKind::Clock => {
1069            append_clock_actions(state, player, db, curriculum, allowed_card_sets, actions)
1070        }
1071        DecisionKind::Main => {
1072            append_main_actions(state, player, db, curriculum, allowed_card_sets, actions)
1073        }
1074        DecisionKind::Climax => {
1075            append_climax_actions(state, player, db, curriculum, allowed_card_sets, actions)
1076        }
1077        DecisionKind::AttackDeclaration => {
1078            append_attack_declaration_actions(state, decision.player, curriculum, actions)
1079        }
1080        DecisionKind::LevelUp => append_level_up_actions(state, player, actions),
1081        DecisionKind::Encore => append_encore_actions(state, player, db, curriculum, actions),
1082        DecisionKind::TriggerOrder => append_trigger_order_actions(state, actions),
1083        DecisionKind::Choice => append_choice_actions(state, actions),
1084    }
1085    if curriculum.allow_concede {
1086        actions.push(ActionDesc::Concede);
1087    }
1088}
1089
1090fn is_character_slot(slot: &StageSlot, db: &CardDb) -> bool {
1091    slot.card
1092        .and_then(|inst| db.get(inst.id))
1093        .map(|c| c.card_type == CardType::Character)
1094        .unwrap_or(false)
1095}