Skip to main content

weiss_core/legal/
ids.rs

1use std::collections::HashSet;
2
3use crate::config::CurriculumConfig;
4use crate::db::CardDb;
5use crate::encode::{
6    ATTACK_BASE, CHOICE_BASE, CHOICE_COUNT, CHOICE_NEXT_ID, CHOICE_PREV_ID, CLIMAX_PLAY_BASE,
7    CLOCK_HAND_BASE, CONCEDE_ID, ENCORE_DECLINE_BASE, ENCORE_PAY_BASE, LEVEL_UP_BASE,
8    MAIN_MOVE_BASE, MAIN_PLAY_CHAR_BASE, MAIN_PLAY_EVENT_BASE, MULLIGAN_CONFIRM_ID,
9    MULLIGAN_SELECT_BASE, PASS_ACTION_ID, TRIGGER_ORDER_BASE,
10};
11use crate::state::{AttackType, GameState, StageStatus};
12
13use super::attack::can_declare_attack;
14use super::hand_play_requirements::card_set_allowed;
15use super::helpers::{
16    attack_type_to_index, can_pay_encore_for_slot, for_each_playable_hand_card, is_character_slot,
17    push_id, starting_player_first_turn_attack_used, HandScanMode, PlayableHandCard,
18    StageModifierCache,
19};
20use super::types::{Decision, DecisionKind, LegalActionIds};
21use super::{MAX_HAND, MAX_STAGE};
22
23#[inline(always)]
24fn append_mulligan_action_ids(state: &GameState, player: usize, out: &mut LegalActionIds) {
25    push_id(out, MULLIGAN_CONFIRM_ID);
26    let p = &state.players[player];
27    for (hand_index, _) in p.hand.iter().enumerate() {
28        if hand_index >= MAX_HAND || hand_index > u8::MAX as usize {
29            break;
30        }
31        push_id(out, MULLIGAN_SELECT_BASE + hand_index);
32    }
33}
34
35#[inline(always)]
36fn append_clock_action_ids(
37    state: &GameState,
38    player: usize,
39    db: &CardDb,
40    curriculum: &CurriculumConfig,
41    allowed_card_sets: Option<&HashSet<String>>,
42    out: &mut LegalActionIds,
43) {
44    push_id(out, PASS_ACTION_ID);
45    let p = &state.players[player];
46    for (hand_index, card_inst) in p.hand.iter().enumerate() {
47        if hand_index >= MAX_HAND || hand_index > u8::MAX as usize {
48            break;
49        }
50        if let Some(card) = db.get(card_inst.id) {
51            if !card_set_allowed(card, curriculum, allowed_card_sets) {
52                continue;
53            }
54            push_id(out, CLOCK_HAND_BASE + hand_index);
55        }
56    }
57}
58
59#[inline(always)]
60fn append_main_action_ids(
61    state: &GameState,
62    player: usize,
63    db: &CardDb,
64    curriculum: &CurriculumConfig,
65    allowed_card_sets: Option<&HashSet<String>>,
66    out: &mut LegalActionIds,
67) {
68    let p = &state.players[player];
69    let modifier_cache = StageModifierCache::build(state, player);
70    let max_slot = if curriculum.reduced_stage_mode {
71        1
72    } else {
73        MAX_STAGE
74    };
75    let events_locked = modifier_cache.cannot_play_events_from_hand;
76    push_id(out, PASS_ACTION_ID);
77    for_each_playable_hand_card(
78        p,
79        db,
80        curriculum,
81        allowed_card_sets,
82        HandScanMode::Main,
83        events_locked,
84        |playable| match playable {
85            PlayableHandCard::MainCharacter { hand_index } => {
86                for slot in 0..max_slot {
87                    let id = MAIN_PLAY_CHAR_BASE + hand_index * MAX_STAGE + slot;
88                    push_id(out, id);
89                }
90            }
91            PlayableHandCard::MainEvent { hand_index } => {
92                push_id(out, MAIN_PLAY_EVENT_BASE + hand_index);
93            }
94            PlayableHandCard::Climax { .. } => {}
95        },
96    );
97    if !state.turn.main_move_used {
98        for from in 0..max_slot {
99            for to in 0..max_slot {
100                if from == to {
101                    continue;
102                }
103                let from_slot = &p.stage[from];
104                let to_slot = &p.stage[to];
105                if from_slot.card.is_some()
106                    && is_character_slot(from_slot, db)
107                    && (to_slot.card.is_none() || is_character_slot(to_slot, db))
108                    && !modifier_cache.cannot_move_stage_position[from]
109                    && (to_slot.card.is_none() || !modifier_cache.cannot_move_stage_position[to])
110                {
111                    let to_index = if to < from { to } else { to - 1 };
112                    let id = MAIN_MOVE_BASE + from * (MAX_STAGE - 1) + to_index;
113                    push_id(out, id);
114                }
115            }
116        }
117    }
118}
119
120#[inline(always)]
121fn append_climax_action_ids(
122    state: &GameState,
123    player: usize,
124    db: &CardDb,
125    curriculum: &CurriculumConfig,
126    allowed_card_sets: Option<&HashSet<String>>,
127    out: &mut LegalActionIds,
128) {
129    let p = &state.players[player];
130    push_id(out, PASS_ACTION_ID);
131    for_each_playable_hand_card(
132        p,
133        db,
134        curriculum,
135        allowed_card_sets,
136        HandScanMode::Climax,
137        false,
138        |playable| {
139            if let PlayableHandCard::Climax { hand_index } = playable {
140                push_id(out, CLIMAX_PLAY_BASE + hand_index);
141            }
142        },
143    );
144}
145
146#[inline(always)]
147fn append_attack_declaration_action_ids(
148    state: &GameState,
149    player: u8,
150    curriculum: &CurriculumConfig,
151    out: &mut LegalActionIds,
152) {
153    push_id(out, PASS_ACTION_ID);
154    if starting_player_first_turn_attack_used(state, player) {
155        return;
156    }
157    let max_slot = if curriculum.reduced_stage_mode { 1 } else { 3 };
158    for slot in 0..max_slot {
159        let slot_u8 = slot as u8;
160        for attack_type in [AttackType::Frontal, AttackType::Side, AttackType::Direct] {
161            if can_declare_attack(state, player, slot_u8, attack_type, curriculum).is_ok() {
162                let id = ATTACK_BASE + slot * 3 + attack_type_to_index(attack_type);
163                push_id(out, id);
164            }
165        }
166    }
167}
168
169#[inline(always)]
170fn append_level_up_action_ids(state: &GameState, player: usize, out: &mut LegalActionIds) {
171    if state.players[player].clock.len() >= 7 {
172        for idx in 0..7 {
173            push_id(out, LEVEL_UP_BASE + idx);
174        }
175    }
176}
177
178#[inline(always)]
179fn append_encore_action_ids(
180    state: &GameState,
181    player: usize,
182    db: &CardDb,
183    curriculum: &CurriculumConfig,
184    out: &mut LegalActionIds,
185) {
186    let p = &state.players[player];
187    let modifier_cache = StageModifierCache::build(state, player);
188    for slot in 0..p.stage.len() {
189        if p.stage[slot].card.is_some()
190            && p.stage[slot].status == StageStatus::Reverse
191            && can_pay_encore_for_slot(state, db, curriculum, player, slot, Some(&modifier_cache))
192        {
193            push_id(out, ENCORE_PAY_BASE + slot);
194        }
195    }
196    for slot in 0..p.stage.len() {
197        if p.stage[slot].card.is_some() && p.stage[slot].status == StageStatus::Reverse {
198            push_id(out, ENCORE_DECLINE_BASE + slot);
199        }
200    }
201}
202
203#[inline(always)]
204fn append_trigger_order_action_ids(state: &GameState, out: &mut LegalActionIds) {
205    let choices = state
206        .turn
207        .trigger_order
208        .as_ref()
209        .map(|o| o.choices.len())
210        .unwrap_or(0);
211    let max = choices.min(10);
212    for idx in 0..max {
213        push_id(out, TRIGGER_ORDER_BASE + idx);
214    }
215}
216
217#[inline(always)]
218fn append_choice_action_ids(state: &GameState, out: &mut LegalActionIds) {
219    if let Some(choice) = state.turn.choice.as_ref() {
220        let total = choice.total_candidates as usize;
221        let page_start = choice.page_start as usize;
222        let safe_start = page_start.min(total);
223        let page_end = total.min(safe_start + CHOICE_COUNT);
224        for idx in 0..(page_end - safe_start) {
225            push_id(out, CHOICE_BASE + idx);
226        }
227        if page_start >= CHOICE_COUNT {
228            push_id(out, CHOICE_PREV_ID);
229        }
230        if page_start + CHOICE_COUNT < total {
231            push_id(out, CHOICE_NEXT_ID);
232        }
233    }
234}
235
236/// Compute legal action ids for a decision into a reusable buffer.
237#[inline(always)]
238pub fn legal_action_ids_cached_into(
239    state: &GameState,
240    decision: &Decision,
241    db: &CardDb,
242    curriculum: &CurriculumConfig,
243    allowed_card_sets: Option<&HashSet<String>>,
244    out: &mut LegalActionIds,
245) {
246    // Invariants:
247    // - Preserve canonical legal action ordering and action-id packing per decision.
248    // - Keep descriptor/id parity covered by `weiss_core/tests/legal_cache_parity_tests.rs`.
249    let player = decision.player as usize;
250    out.clear();
251    match decision.kind {
252        DecisionKind::Mulligan => append_mulligan_action_ids(state, player, out),
253        DecisionKind::Clock => {
254            append_clock_action_ids(state, player, db, curriculum, allowed_card_sets, out)
255        }
256        DecisionKind::Main => {
257            append_main_action_ids(state, player, db, curriculum, allowed_card_sets, out)
258        }
259        DecisionKind::Climax => {
260            append_climax_action_ids(state, player, db, curriculum, allowed_card_sets, out)
261        }
262        DecisionKind::AttackDeclaration => {
263            append_attack_declaration_action_ids(state, decision.player, curriculum, out)
264        }
265        DecisionKind::LevelUp => append_level_up_action_ids(state, player, out),
266        DecisionKind::Encore => append_encore_action_ids(state, player, db, curriculum, out),
267        DecisionKind::TriggerOrder => append_trigger_order_action_ids(state, out),
268        DecisionKind::Choice => append_choice_action_ids(state, out),
269    }
270    if curriculum.allow_concede {
271        push_id(out, CONCEDE_ID);
272    }
273}