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#[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#[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#[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 }
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}