Skip to main content

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    if !state.turn.main_move_used {
510        for from in 0..max_slot {
511            for to in 0..max_slot {
512                if from == to {
513                    continue;
514                }
515                let from_slot = &p.stage[from];
516                let to_slot = &p.stage[to];
517                if from_slot.card.is_some()
518                    && is_character_slot(from_slot, db)
519                    && (to_slot.card.is_none() || is_character_slot(to_slot, db))
520                    && !modifier_cache.cannot_move_stage_position[from]
521                    && (to_slot.card.is_none() || !modifier_cache.cannot_move_stage_position[to])
522                {
523                    let to_index = if to < from { to } else { to - 1 };
524                    let id = MAIN_MOVE_BASE + from * (MAX_STAGE - 1) + to_index;
525                    push_id(out, id);
526                }
527            }
528        }
529    }
530}
531
532#[inline(always)]
533fn append_climax_action_ids(
534    state: &GameState,
535    player: usize,
536    db: &CardDb,
537    curriculum: &CurriculumConfig,
538    allowed_card_sets: Option<&HashSet<String>>,
539    out: &mut LegalActionIds,
540) {
541    let p = &state.players[player];
542    push_id(out, PASS_ACTION_ID);
543    for_each_playable_hand_card(
544        p,
545        db,
546        curriculum,
547        allowed_card_sets,
548        HandScanMode::Climax,
549        false,
550        |playable| {
551            if let PlayableHandCard::Climax { hand_index } = playable {
552                push_id(out, CLIMAX_PLAY_BASE + hand_index);
553            }
554        },
555    );
556}
557
558#[inline(always)]
559fn append_attack_declaration_action_ids(
560    state: &GameState,
561    player: u8,
562    curriculum: &CurriculumConfig,
563    out: &mut LegalActionIds,
564) {
565    push_id(out, PASS_ACTION_ID);
566    if starting_player_first_turn_attack_used(state, player) {
567        return;
568    }
569    let max_slot = if curriculum.reduced_stage_mode { 1 } else { 3 };
570    for slot in 0..max_slot {
571        let slot_u8 = slot as u8;
572        for attack_type in [AttackType::Frontal, AttackType::Side, AttackType::Direct] {
573            if can_declare_attack(state, player, slot_u8, attack_type, curriculum).is_ok() {
574                let id = ATTACK_BASE + slot * 3 + attack_type_to_index(attack_type);
575                push_id(out, id);
576            }
577        }
578    }
579}
580
581#[inline(always)]
582fn append_level_up_action_ids(state: &GameState, player: usize, out: &mut LegalActionIds) {
583    if state.players[player].clock.len() >= 7 {
584        for idx in 0..7 {
585            push_id(out, LEVEL_UP_BASE + idx);
586        }
587    }
588}
589
590#[inline(always)]
591fn append_encore_action_ids(
592    state: &GameState,
593    player: usize,
594    db: &CardDb,
595    curriculum: &CurriculumConfig,
596    out: &mut LegalActionIds,
597) {
598    let p = &state.players[player];
599    let modifier_cache = StageModifierCache::build(state, player);
600    for slot in 0..p.stage.len() {
601        if p.stage[slot].card.is_some()
602            && p.stage[slot].status == StageStatus::Reverse
603            && can_pay_encore_for_slot(state, db, curriculum, player, slot, Some(&modifier_cache))
604        {
605            push_id(out, ENCORE_PAY_BASE + slot);
606        }
607    }
608    for slot in 0..p.stage.len() {
609        if p.stage[slot].card.is_some() && p.stage[slot].status == StageStatus::Reverse {
610            push_id(out, ENCORE_DECLINE_BASE + slot);
611        }
612    }
613}
614
615#[inline(always)]
616fn append_trigger_order_action_ids(state: &GameState, out: &mut LegalActionIds) {
617    let choices = state
618        .turn
619        .trigger_order
620        .as_ref()
621        .map(|o| o.choices.len())
622        .unwrap_or(0);
623    let max = choices.min(10);
624    for idx in 0..max {
625        push_id(out, TRIGGER_ORDER_BASE + idx);
626    }
627}
628
629#[inline(always)]
630fn append_choice_action_ids(state: &GameState, out: &mut LegalActionIds) {
631    if let Some(choice) = state.turn.choice.as_ref() {
632        let total = choice.total_candidates as usize;
633        let page_start = choice.page_start as usize;
634        let safe_start = page_start.min(total);
635        let page_end = total.min(safe_start + CHOICE_COUNT);
636        for idx in 0..(page_end - safe_start) {
637            push_id(out, CHOICE_BASE + idx);
638        }
639        if page_start >= CHOICE_COUNT {
640            push_id(out, CHOICE_PREV_ID);
641        }
642        if page_start + CHOICE_COUNT < total {
643            push_id(out, CHOICE_NEXT_ID);
644        }
645    }
646}
647
648#[inline(always)]
649fn append_mulligan_actions(state: &GameState, player: usize, actions: &mut LegalActions) {
650    let p = &state.players[player];
651    actions.push(ActionDesc::MulliganConfirm);
652    for (hand_index, _) in p.hand.iter().enumerate() {
653        if hand_index >= MAX_HAND || hand_index > u8::MAX as usize {
654            break;
655        }
656        actions.push(ActionDesc::MulliganSelect {
657            hand_index: hand_index as u8,
658        });
659    }
660}
661
662#[inline(always)]
663fn append_clock_actions(
664    state: &GameState,
665    player: usize,
666    db: &CardDb,
667    curriculum: &CurriculumConfig,
668    allowed_card_sets: Option<&HashSet<String>>,
669    actions: &mut LegalActions,
670) {
671    actions.push(ActionDesc::Pass);
672    let p = &state.players[player];
673    for (hand_index, card_inst) in p.hand.iter().enumerate() {
674        if hand_index >= MAX_HAND || hand_index > u8::MAX as usize {
675            break;
676        }
677        if let Some(card) = db.get(card_inst.id) {
678            if !card_set_allowed(card, curriculum, allowed_card_sets) {
679                continue;
680            }
681            actions.push(ActionDesc::Clock {
682                hand_index: hand_index as u8,
683            });
684        }
685    }
686}
687
688#[inline(always)]
689fn append_main_actions(
690    state: &GameState,
691    player: usize,
692    db: &CardDb,
693    curriculum: &CurriculumConfig,
694    allowed_card_sets: Option<&HashSet<String>>,
695    actions: &mut LegalActions,
696) {
697    let p = &state.players[player];
698    let modifier_cache = StageModifierCache::build(state, player);
699    let max_slot = if curriculum.reduced_stage_mode {
700        1
701    } else {
702        MAX_STAGE
703    };
704    let events_locked = modifier_cache.cannot_play_events_from_hand;
705    actions.push(ActionDesc::Pass);
706    for_each_playable_hand_card(
707        p,
708        db,
709        curriculum,
710        allowed_card_sets,
711        HandScanMode::Main,
712        events_locked,
713        |playable| match playable {
714            PlayableHandCard::MainCharacter { hand_index } => {
715                for slot in 0..max_slot {
716                    actions.push(ActionDesc::MainPlayCharacter {
717                        hand_index: hand_index as u8,
718                        stage_slot: slot as u8,
719                    });
720                }
721            }
722            PlayableHandCard::MainEvent { hand_index } => {
723                actions.push(ActionDesc::MainPlayEvent {
724                    hand_index: hand_index as u8,
725                });
726            }
727            PlayableHandCard::Climax { .. } => {}
728        },
729    );
730    if !state.turn.main_move_used {
731        for from in 0..max_slot {
732            for to in 0..max_slot {
733                if from == to {
734                    continue;
735                }
736                let from_slot = &p.stage[from];
737                let to_slot = &p.stage[to];
738                if from_slot.card.is_some()
739                    && is_character_slot(from_slot, db)
740                    && (to_slot.card.is_none() || is_character_slot(to_slot, db))
741                    && !modifier_cache.cannot_move_stage_position[from]
742                    && (to_slot.card.is_none() || !modifier_cache.cannot_move_stage_position[to])
743                {
744                    actions.push(ActionDesc::MainMove {
745                        from_slot: from as u8,
746                        to_slot: to as u8,
747                    });
748                }
749            }
750        }
751    }
752}
753
754#[inline(always)]
755fn append_climax_actions(
756    state: &GameState,
757    player: usize,
758    db: &CardDb,
759    curriculum: &CurriculumConfig,
760    allowed_card_sets: Option<&HashSet<String>>,
761    actions: &mut LegalActions,
762) {
763    let p = &state.players[player];
764    actions.push(ActionDesc::Pass);
765    for_each_playable_hand_card(
766        p,
767        db,
768        curriculum,
769        allowed_card_sets,
770        HandScanMode::Climax,
771        false,
772        |playable| {
773            if let PlayableHandCard::Climax { hand_index } = playable {
774                actions.push(ActionDesc::ClimaxPlay {
775                    hand_index: hand_index as u8,
776                });
777            }
778        },
779    );
780}
781
782#[inline(always)]
783fn append_attack_declaration_actions(
784    state: &GameState,
785    player: u8,
786    curriculum: &CurriculumConfig,
787    actions: &mut LegalActions,
788) {
789    actions.push(ActionDesc::Pass);
790    legal_attack_actions_into(state, player, curriculum, actions);
791}
792
793#[inline(always)]
794fn append_level_up_actions(state: &GameState, player: usize, actions: &mut LegalActions) {
795    if state.players[player].clock.len() >= 7 {
796        for idx in 0..7 {
797            actions.push(ActionDesc::LevelUp { index: idx as u8 });
798        }
799    }
800}
801
802#[inline(always)]
803fn append_encore_actions(
804    state: &GameState,
805    player: usize,
806    db: &CardDb,
807    curriculum: &CurriculumConfig,
808    actions: &mut LegalActions,
809) {
810    let p = &state.players[player];
811    let modifier_cache = StageModifierCache::build(state, player);
812    for slot in 0..p.stage.len() {
813        if p.stage[slot].card.is_some()
814            && p.stage[slot].status == StageStatus::Reverse
815            && can_pay_encore_for_slot(state, db, curriculum, player, slot, Some(&modifier_cache))
816        {
817            actions.push(ActionDesc::EncorePay { slot: slot as u8 });
818        }
819    }
820    for slot in 0..p.stage.len() {
821        if p.stage[slot].card.is_some() && p.stage[slot].status == StageStatus::Reverse {
822            actions.push(ActionDesc::EncoreDecline { slot: slot as u8 });
823        }
824    }
825}
826
827#[inline(always)]
828fn append_trigger_order_actions(state: &GameState, actions: &mut LegalActions) {
829    let choices = state
830        .turn
831        .trigger_order
832        .as_ref()
833        .map(|o| o.choices.len())
834        .unwrap_or(0);
835    let max = choices.min(10);
836    for idx in 0..max {
837        actions.push(ActionDesc::TriggerOrder { index: idx as u8 });
838    }
839}
840
841#[inline(always)]
842fn append_choice_actions(state: &GameState, actions: &mut LegalActions) {
843    if let Some(choice) = state.turn.choice.as_ref() {
844        let total = choice.total_candidates as usize;
845        let page_start = choice.page_start as usize;
846        let safe_start = page_start.min(total);
847        let page_end = total.min(safe_start + CHOICE_COUNT);
848        for idx in 0..(page_end - safe_start) {
849            actions.push(ActionDesc::ChoiceSelect { index: idx as u8 });
850        }
851        if page_start >= CHOICE_COUNT {
852            actions.push(ActionDesc::ChoicePrevPage);
853        }
854        if page_start + CHOICE_COUNT < total {
855            actions.push(ActionDesc::ChoiceNextPage);
856        }
857    }
858}
859
860/// Compute legal action ids for a decision into a reusable buffer.
861#[inline(always)]
862pub fn legal_action_ids_cached_into(
863    state: &GameState,
864    decision: &Decision,
865    db: &CardDb,
866    curriculum: &CurriculumConfig,
867    allowed_card_sets: Option<&HashSet<String>>,
868    out: &mut LegalActionIds,
869) {
870    // Invariants:
871    // - Preserve canonical legal action ordering and action-id packing per decision.
872    // - Keep descriptor/id parity covered by `weiss_core/tests/legal_cache_parity_tests.rs`.
873    let player = decision.player as usize;
874    out.clear();
875    match decision.kind {
876        DecisionKind::Mulligan => append_mulligan_action_ids(state, player, out),
877        DecisionKind::Clock => {
878            append_clock_action_ids(state, player, db, curriculum, allowed_card_sets, out)
879        }
880        DecisionKind::Main => {
881            append_main_action_ids(state, player, db, curriculum, allowed_card_sets, out)
882        }
883        DecisionKind::Climax => {
884            append_climax_action_ids(state, player, db, curriculum, allowed_card_sets, out)
885        }
886        DecisionKind::AttackDeclaration => {
887            append_attack_declaration_action_ids(state, decision.player, curriculum, out)
888        }
889        DecisionKind::LevelUp => append_level_up_action_ids(state, player, out),
890        DecisionKind::Encore => append_encore_action_ids(state, player, db, curriculum, out),
891        DecisionKind::TriggerOrder => append_trigger_order_action_ids(state, out),
892        DecisionKind::Choice => append_choice_action_ids(state, out),
893    }
894    if curriculum.allow_concede {
895        push_id(out, CONCEDE_ID);
896    }
897}
898
899/// Validate whether an attack can be declared from a slot.
900pub fn can_declare_attack(
901    state: &GameState,
902    player: u8,
903    slot: u8,
904    attack_type: AttackType,
905    curriculum: &CurriculumConfig,
906) -> Result<(), &'static str> {
907    let p = player as usize;
908    let s = slot as usize;
909    if s >= MAX_STAGE || (curriculum.reduced_stage_mode && s > 0) {
910        return Err("Attack slot out of range");
911    }
912    if s >= 3 {
913        return Err("Attack must be from center stage");
914    }
915    let attacker_slot = &state.players[p].stage[s];
916    if attacker_slot.card.is_none() {
917        return Err("No attacker in slot");
918    }
919    if attacker_slot.status != StageStatus::Stand {
920        return Err("Attacker is rested");
921    }
922    if attacker_slot.has_attacked {
923        return Err("Attacker already attacked");
924    }
925    if starting_player_first_turn_attack_used(state, player) {
926        return Err("Starting player can only attack once on first turn");
927    }
928    let (cannot_attack, cannot_side_attack, cannot_frontal_attack, attack_cost) =
929        if let Some(derived) = state.turn.derived_attack.as_ref() {
930            let entry = derived.per_player[p][s];
931            (
932                entry.cannot_attack,
933                entry.cannot_side_attack,
934                entry.cannot_frontal_attack,
935                entry.attack_cost,
936            )
937        } else if let Some(card_inst) = attacker_slot.card {
938            collect_attack_slot_state(
939                state,
940                p,
941                s,
942                card_inst.id,
943                attacker_slot.cannot_attack,
944                attacker_slot.attack_cost,
945            )
946        } else {
947            (
948                attacker_slot.cannot_attack,
949                false,
950                false,
951                attacker_slot.attack_cost,
952            )
953        };
954    if cannot_attack {
955        return Err("Attacker cannot attack");
956    }
957    if attack_cost as usize > state.players[p].stock.len() {
958        return Err("Attack cost not payable");
959    }
960    let defender_player = 1 - p;
961    let defender_present = state.players[defender_player].stage[s].card.is_some();
962    match attack_type {
963        AttackType::Frontal | AttackType::Side if !defender_present => {
964            return Err("No defender for frontal/side attack");
965        }
966        AttackType::Frontal if cannot_frontal_attack => {
967            return Err("Attacker cannot frontal attack");
968        }
969        AttackType::Side if cannot_side_attack => {
970            return Err("Attacker cannot side attack");
971        }
972        AttackType::Direct if defender_present => {
973            return Err("Direct attack requires empty opposing slot");
974        }
975        AttackType::Side if !curriculum.enable_side_attacks => {
976            return Err("Side attacks disabled");
977        }
978        AttackType::Direct if !curriculum.enable_direct_attacks => {
979            return Err("Direct attacks disabled");
980        }
981        _ => {}
982    }
983    Ok(())
984}
985
986/// Compute legal attack actions into a reusable buffer.
987#[inline(always)]
988pub fn legal_attack_actions_into(
989    state: &GameState,
990    player: u8,
991    curriculum: &CurriculumConfig,
992    actions: &mut LegalActions,
993) {
994    if starting_player_first_turn_attack_used(state, player) {
995        return;
996    }
997    let max_slot = if curriculum.reduced_stage_mode { 1 } else { 3 };
998    for slot in 0..max_slot {
999        let slot_u8 = slot as u8;
1000        for attack_type in [AttackType::Frontal, AttackType::Side, AttackType::Direct] {
1001            if can_declare_attack(state, player, slot_u8, attack_type, curriculum).is_ok() {
1002                actions.push(ActionDesc::Attack {
1003                    slot: slot_u8,
1004                    attack_type,
1005                });
1006            }
1007        }
1008    }
1009}
1010
1011/// Compute legal attack actions for a player.
1012#[inline(always)]
1013pub fn legal_attack_actions(
1014    state: &GameState,
1015    player: u8,
1016    curriculum: &CurriculumConfig,
1017) -> LegalActions {
1018    let mut actions = LegalActions::new();
1019    legal_attack_actions_into(state, player, curriculum, &mut actions);
1020    actions
1021}
1022
1023/// Compute legal actions for a decision.
1024#[inline(always)]
1025pub fn legal_actions(
1026    state: &GameState,
1027    decision: &Decision,
1028    db: &CardDb,
1029    curriculum: &CurriculumConfig,
1030) -> LegalActions {
1031    legal_actions_cached(state, decision, db, curriculum, None)
1032}
1033
1034/// Compute legal actions using cached data structures where possible.
1035#[inline(always)]
1036pub fn legal_actions_cached(
1037    state: &GameState,
1038    decision: &Decision,
1039    db: &CardDb,
1040    curriculum: &CurriculumConfig,
1041    allowed_card_sets: Option<&HashSet<String>>,
1042) -> LegalActions {
1043    let mut actions = LegalActions::new();
1044    legal_actions_cached_into(
1045        state,
1046        decision,
1047        db,
1048        curriculum,
1049        allowed_card_sets,
1050        &mut actions,
1051    );
1052    actions
1053}
1054
1055/// Compute legal actions into a reusable buffer using cached data.
1056#[inline(always)]
1057pub fn legal_actions_cached_into(
1058    state: &GameState,
1059    decision: &Decision,
1060    db: &CardDb,
1061    curriculum: &CurriculumConfig,
1062    allowed_card_sets: Option<&HashSet<String>>,
1063    actions: &mut LegalActions,
1064) {
1065    // Invariants:
1066    // - Preserve canonical legal action ordering so `action_id_for` stays parity-aligned.
1067    // - Ordering/id parity is covered by `weiss_core/tests/legal_cache_parity_tests.rs`.
1068    let player = decision.player as usize;
1069    actions.clear();
1070    match decision.kind {
1071        DecisionKind::Mulligan => append_mulligan_actions(state, player, actions),
1072        DecisionKind::Clock => {
1073            append_clock_actions(state, player, db, curriculum, allowed_card_sets, actions)
1074        }
1075        DecisionKind::Main => {
1076            append_main_actions(state, player, db, curriculum, allowed_card_sets, actions)
1077        }
1078        DecisionKind::Climax => {
1079            append_climax_actions(state, player, db, curriculum, allowed_card_sets, actions)
1080        }
1081        DecisionKind::AttackDeclaration => {
1082            append_attack_declaration_actions(state, decision.player, curriculum, actions)
1083        }
1084        DecisionKind::LevelUp => append_level_up_actions(state, player, actions),
1085        DecisionKind::Encore => append_encore_actions(state, player, db, curriculum, actions),
1086        DecisionKind::TriggerOrder => append_trigger_order_actions(state, actions),
1087        DecisionKind::Choice => append_choice_actions(state, actions),
1088    }
1089    if curriculum.allow_concede {
1090        actions.push(ActionDesc::Concede);
1091    }
1092}
1093
1094fn is_character_slot(slot: &StageSlot, db: &CardDb) -> bool {
1095    slot.card
1096        .and_then(|inst| db.get(inst.id))
1097        .map(|c| c.card_type == CardType::Character)
1098        .unwrap_or(false)
1099}