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
18pub(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#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
84pub enum DecisionKind {
85 Mulligan,
87 Clock,
89 Main,
91 Climax,
93 AttackDeclaration,
95 LevelUp,
97 Encore,
99 TriggerOrder,
101 Choice,
103}
104
105#[derive(Clone, Debug, Serialize, Deserialize)]
107pub struct Decision {
108 pub player: u8,
110 pub kind: DecisionKind,
112 pub focus_slot: Option<u8>,
114}
115
116#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
118pub enum ActionDesc {
119 MulliganConfirm,
121 MulliganSelect {
123 hand_index: u8,
125 },
126 Pass,
128 Clock {
130 hand_index: u8,
132 },
133 MainPlayCharacter {
135 hand_index: u8,
137 stage_slot: u8,
139 },
140 MainPlayEvent {
142 hand_index: u8,
144 },
145 MainMove {
147 from_slot: u8,
149 to_slot: u8,
151 },
152 MainActivateAbility {
154 slot: u8,
156 ability_index: u8,
158 },
159 ClimaxPlay {
161 hand_index: u8,
163 },
164 Attack {
166 slot: u8,
168 attack_type: AttackType,
170 },
171 CounterPlay {
173 hand_index: u8,
175 },
176 LevelUp {
178 index: u8,
180 },
181 EncorePay {
183 slot: u8,
185 },
186 EncoreDecline {
188 slot: u8,
190 },
191 TriggerOrder {
193 index: u8,
195 },
196 ChoiceSelect {
198 index: u8,
200 },
201 ChoicePrevPage,
203 ChoiceNextPage,
205 Concede,
207}
208
209pub type LegalActions = SmallVec<[ActionDesc; 64]>;
211pub 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#[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 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
899pub 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#[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#[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#[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#[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#[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 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}