weiss_core/
legal.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashSet;
3
4use crate::config::CurriculumConfig;
5use crate::db::{CardColor, CardDb, CardStatic, CardType};
6use crate::state::{AttackType, GameState, StageSlot, StageStatus};
7
8const MAX_HAND: usize = crate::encode::MAX_HAND;
9const MAX_STAGE: usize = 5;
10
11/// Player decision kinds exposed to callers.
12#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
13pub enum DecisionKind {
14    Mulligan,
15    Clock,
16    Main,
17    Climax,
18    AttackDeclaration,
19    LevelUp,
20    Encore,
21    TriggerOrder,
22    Choice,
23}
24
25/// A pending decision describing which player must act next.
26#[derive(Clone, Debug, Serialize, Deserialize)]
27pub struct Decision {
28    pub player: u8,
29    pub kind: DecisionKind,
30    pub focus_slot: Option<u8>,
31}
32
33/// Canonical action descriptor used as the truth representation of legal actions.
34#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
35pub enum ActionDesc {
36    MulliganConfirm,
37    MulliganSelect { hand_index: u8 },
38    Pass,
39    Clock { hand_index: u8 },
40    MainPlayCharacter { hand_index: u8, stage_slot: u8 },
41    MainPlayEvent { hand_index: u8 },
42    MainMove { from_slot: u8, to_slot: u8 },
43    MainActivateAbility { slot: u8, ability_index: u8 },
44    ClimaxPlay { hand_index: u8 },
45    Attack { slot: u8, attack_type: AttackType },
46    CounterPlay { hand_index: u8 },
47    LevelUp { index: u8 },
48    EncorePay { slot: u8 },
49    EncoreDecline { slot: u8 },
50    TriggerOrder { index: u8 },
51    ChoiceSelect { index: u8 },
52    ChoicePrevPage,
53    ChoiceNextPage,
54    Concede,
55}
56
57pub fn can_declare_attack(
58    state: &GameState,
59    player: u8,
60    slot: u8,
61    attack_type: AttackType,
62    curriculum: &CurriculumConfig,
63) -> Result<(), &'static str> {
64    let p = player as usize;
65    let s = slot as usize;
66    if s >= MAX_STAGE || (curriculum.reduced_stage_mode && s > 0) {
67        return Err("Attack slot out of range");
68    }
69    if s >= 3 {
70        return Err("Attack must be from center stage");
71    }
72    let attacker_slot = &state.players[p].stage[s];
73    if attacker_slot.card.is_none() {
74        return Err("No attacker in slot");
75    }
76    if attacker_slot.status != StageStatus::Stand {
77        return Err("Attacker is rested");
78    }
79    if attacker_slot.has_attacked {
80        return Err("Attacker already attacked");
81    }
82    let (cannot_attack, attack_cost) = if let Some(derived) = state.turn.derived_attack.as_ref() {
83        let entry = derived.per_player[p][s];
84        (entry.cannot_attack, entry.attack_cost)
85    } else {
86        (attacker_slot.cannot_attack, attacker_slot.attack_cost)
87    };
88    if cannot_attack {
89        return Err("Attacker cannot attack");
90    }
91    if attack_cost as usize > state.players[p].stock.len() {
92        return Err("Attack cost not payable");
93    }
94    let defender_player = 1 - p;
95    let defender_present = state.players[defender_player].stage[s].card.is_some();
96    match attack_type {
97        AttackType::Frontal | AttackType::Side if !defender_present => {
98            return Err("No defender for frontal/side attack");
99        }
100        AttackType::Direct if defender_present => {
101            return Err("Direct attack requires empty opposing slot");
102        }
103        AttackType::Side if !curriculum.enable_side_attacks => {
104            return Err("Side attacks disabled");
105        }
106        AttackType::Direct if !curriculum.enable_direct_attacks => {
107            return Err("Direct attacks disabled");
108        }
109        _ => {}
110    }
111    Ok(())
112}
113
114pub fn legal_attack_actions(
115    state: &GameState,
116    player: u8,
117    curriculum: &CurriculumConfig,
118) -> Vec<ActionDesc> {
119    if state.turn.turn_number == 0 && player == state.turn.starting_player {
120        return Vec::new();
121    }
122    let mut actions = Vec::new();
123    let max_slot = if curriculum.reduced_stage_mode { 1 } else { 3 };
124    for slot in 0..max_slot {
125        let slot_u8 = slot as u8;
126        for attack_type in [AttackType::Frontal, AttackType::Side, AttackType::Direct] {
127            if can_declare_attack(state, player, slot_u8, attack_type, curriculum).is_ok() {
128                actions.push(ActionDesc::Attack {
129                    slot: slot_u8,
130                    attack_type,
131                });
132            }
133        }
134    }
135    actions
136}
137
138pub fn legal_actions(
139    state: &GameState,
140    decision: &Decision,
141    db: &CardDb,
142    curriculum: &CurriculumConfig,
143) -> Vec<ActionDesc> {
144    legal_actions_cached(state, decision, db, curriculum, None)
145}
146
147pub fn legal_actions_cached(
148    state: &GameState,
149    decision: &Decision,
150    db: &CardDb,
151    curriculum: &CurriculumConfig,
152    allowed_card_sets: Option<&HashSet<String>>,
153) -> Vec<ActionDesc> {
154    let player = decision.player as usize;
155    let mut actions = match decision.kind {
156        DecisionKind::Mulligan => {
157            let mut actions = Vec::new();
158            let p = &state.players[player];
159            actions.push(ActionDesc::MulliganConfirm);
160            for (hand_index, _) in p.hand.iter().enumerate() {
161                if hand_index >= MAX_HAND || hand_index > u8::MAX as usize {
162                    break;
163                }
164                actions.push(ActionDesc::MulliganSelect {
165                    hand_index: hand_index as u8,
166                });
167            }
168            actions
169        }
170        DecisionKind::Clock => {
171            let mut actions = Vec::new();
172            actions.push(ActionDesc::Pass);
173            let p = &state.players[player];
174            for (hand_index, card_inst) in p.hand.iter().enumerate() {
175                if hand_index >= MAX_HAND || hand_index > u8::MAX as usize {
176                    break;
177                }
178                if let Some(card) = db.get(card_inst.id) {
179                    if !card_set_allowed(card, curriculum, allowed_card_sets) {
180                        continue;
181                    }
182                    actions.push(ActionDesc::Clock {
183                        hand_index: hand_index as u8,
184                    });
185                }
186            }
187            actions
188        }
189        DecisionKind::Main => {
190            let mut actions = Vec::new();
191            let p = &state.players[player];
192            let max_slot = if curriculum.reduced_stage_mode {
193                1
194            } else {
195                MAX_STAGE
196            };
197            for (hand_index, card_inst) in p.hand.iter().enumerate() {
198                if hand_index >= MAX_HAND || hand_index > u8::MAX as usize {
199                    break;
200                }
201                if let Some(card) = db.get(card_inst.id) {
202                    if !card_set_allowed(card, curriculum, allowed_card_sets) {
203                        continue;
204                    }
205                    match card.card_type {
206                        CardType::Character => {
207                            if curriculum.allow_character
208                                && meets_level_requirement(card, p.level.len())
209                                && meets_color_requirement(card, p, db, curriculum)
210                                && meets_cost_requirement(card, p, curriculum)
211                            {
212                                for slot in 0..max_slot {
213                                    actions.push(ActionDesc::MainPlayCharacter {
214                                        hand_index: hand_index as u8,
215                                        stage_slot: slot as u8,
216                                    });
217                                }
218                            }
219                        }
220                        CardType::Event => {
221                            if curriculum.allow_event
222                                && meets_level_requirement(card, p.level.len())
223                                && meets_color_requirement(card, p, db, curriculum)
224                                && meets_cost_requirement(card, p, curriculum)
225                            {
226                                actions.push(ActionDesc::MainPlayEvent {
227                                    hand_index: hand_index as u8,
228                                });
229                            }
230                        }
231                        CardType::Climax => {
232                            // Climax cards are played in the Climax phase.
233                        }
234                    }
235                }
236            }
237            for from in 0..max_slot {
238                for to in 0..max_slot {
239                    if from == to {
240                        continue;
241                    }
242                    let from_slot = &p.stage[from];
243                    let to_slot = &p.stage[to];
244                    if from_slot.card.is_some()
245                        && is_character_slot(from_slot, db)
246                        && (to_slot.card.is_none() || is_character_slot(to_slot, db))
247                    {
248                        actions.push(ActionDesc::MainMove {
249                            from_slot: from as u8,
250                            to_slot: to as u8,
251                        });
252                    }
253                }
254            }
255            actions.push(ActionDesc::Pass);
256            actions
257        }
258        DecisionKind::Climax => {
259            let mut actions = Vec::new();
260            let p = &state.players[player];
261            if curriculum.enable_climax_phase {
262                for (hand_index, card_inst) in p.hand.iter().enumerate() {
263                    if hand_index >= MAX_HAND || hand_index > u8::MAX as usize {
264                        break;
265                    }
266                    if let Some(card) = db.get(card_inst.id) {
267                        if !card_set_allowed(card, curriculum, allowed_card_sets) {
268                            continue;
269                        }
270                        if card.card_type == CardType::Climax
271                            && curriculum.allow_climax
272                            && p.climax.is_empty()
273                            && meets_level_requirement(card, p.level.len())
274                            && meets_color_requirement(card, p, db, curriculum)
275                            && meets_cost_requirement(card, p, curriculum)
276                        {
277                            actions.push(ActionDesc::ClimaxPlay {
278                                hand_index: hand_index as u8,
279                            });
280                        }
281                    }
282                }
283            }
284            actions.push(ActionDesc::Pass);
285            actions
286        }
287        DecisionKind::AttackDeclaration => {
288            let mut actions = Vec::new();
289            let attacks = legal_attack_actions(state, decision.player, curriculum);
290            actions.extend(attacks);
291            actions.push(ActionDesc::Pass);
292            actions
293        }
294        DecisionKind::LevelUp => {
295            let mut actions = Vec::new();
296            if state.players[player].clock.len() >= 7 {
297                actions.extend((0..7).map(|idx| ActionDesc::LevelUp { index: idx }));
298            }
299            actions
300        }
301        DecisionKind::Encore => {
302            let mut actions = Vec::new();
303            let p = &state.players[player];
304            let can_pay = p.stock.len() >= 3;
305            for slot in 0..p.stage.len() {
306                if p.stage[slot].card.is_some() && p.stage[slot].status == StageStatus::Reverse {
307                    if can_pay {
308                        actions.push(ActionDesc::EncorePay { slot: slot as u8 });
309                    }
310                    actions.push(ActionDesc::EncoreDecline { slot: slot as u8 });
311                }
312            }
313            actions
314        }
315        DecisionKind::TriggerOrder => {
316            let mut actions = Vec::new();
317            let choices = state
318                .turn
319                .trigger_order
320                .as_ref()
321                .map(|o| o.choices.len())
322                .unwrap_or(0);
323            let max = choices.min(10);
324            for idx in 0..max {
325                actions.push(ActionDesc::TriggerOrder { index: idx as u8 });
326            }
327            actions
328        }
329        DecisionKind::Choice => {
330            let mut actions = Vec::new();
331            if let Some(choice) = state.turn.choice.as_ref() {
332                let total = choice.total_candidates as usize;
333                let page_size = crate::encode::CHOICE_COUNT;
334                let page_start = choice.page_start as usize;
335                let safe_start = page_start.min(total);
336                let page_end = total.min(safe_start + page_size);
337                for idx in 0..(page_end - safe_start) {
338                    actions.push(ActionDesc::ChoiceSelect { index: idx as u8 });
339                }
340                if page_start >= page_size {
341                    actions.push(ActionDesc::ChoicePrevPage);
342                }
343                if page_start + page_size < total {
344                    actions.push(ActionDesc::ChoiceNextPage);
345                }
346            }
347            actions
348        }
349    };
350    if curriculum.allow_concede {
351        actions.push(ActionDesc::Concede);
352    }
353    actions
354}
355
356fn card_set_allowed(
357    card: &CardStatic,
358    curriculum: &CurriculumConfig,
359    allowed_card_sets: Option<&HashSet<String>>,
360) -> bool {
361    match (allowed_card_sets, &card.card_set) {
362        (Some(set), Some(set_id)) => set.contains(set_id),
363        (Some(_), None) => false,
364        (None, _) => {
365            if curriculum.allowed_card_sets.is_empty() {
366                true
367            } else {
368                card.card_set
369                    .as_ref()
370                    .map(|s| curriculum.allowed_card_sets.iter().any(|a| a == s))
371                    .unwrap_or(false)
372            }
373        }
374    }
375}
376
377fn meets_level_requirement(card: &CardStatic, level_count: usize) -> bool {
378    card.level as usize <= level_count
379}
380
381fn meets_cost_requirement(
382    card: &CardStatic,
383    player: &crate::state::PlayerState,
384    curriculum: &CurriculumConfig,
385) -> bool {
386    if !curriculum.enforce_cost_requirement {
387        return true;
388    }
389    player.stock.len() >= card.cost as usize
390}
391
392fn meets_color_requirement(
393    card: &CardStatic,
394    player: &crate::state::PlayerState,
395    db: &CardDb,
396    curriculum: &CurriculumConfig,
397) -> bool {
398    if !curriculum.enforce_color_requirement {
399        return true;
400    }
401    if card.level == 0 || card.color == CardColor::Colorless {
402        return true;
403    }
404    for card_id in player.level.iter().chain(player.clock.iter()) {
405        if let Some(c) = db.get(card_id.id) {
406            if c.color == card.color {
407                return true;
408            }
409        }
410    }
411    false
412}
413
414fn is_character_slot(slot: &StageSlot, db: &CardDb) -> bool {
415    slot.card
416        .and_then(|inst| db.get(inst.id))
417        .map(|c| c.card_type == CardType::Character)
418        .unwrap_or(false)
419}