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