weiss_core/
encode.rs

1use crate::config::{CurriculumConfig, ObservationVisibility};
2use crate::db::CardDb;
3use crate::legal::{ActionDesc, Decision, DecisionKind};
4use crate::state::{
5    AttackType, GameState, ModifierKind, Phase, StageStatus, TerminalResult, REVEAL_HISTORY_LEN,
6};
7
8pub const OBS_ENCODING_VERSION: u32 = 1;
9pub const ACTION_ENCODING_VERSION: u32 = 1;
10pub const POLICY_VERSION: u32 = 1;
11pub const SPEC_HASH: u64 = ((OBS_ENCODING_VERSION as u64) << 32)
12    | ((ACTION_ENCODING_VERSION as u64) << 16)
13    | (POLICY_VERSION as u64);
14
15pub const MAX_HAND: usize = 50;
16pub const MAX_DECK: usize = 50;
17pub const MAX_STAGE: usize = 5;
18pub const MAX_ABILITIES_PER_CARD: usize = 4;
19pub const ATTACK_SLOT_COUNT: usize = 3;
20pub const MAX_LEVEL: usize = 4;
21pub const TOP_CLOCK: usize = 7;
22pub const TOP_WAITING_ROOM: usize = 5;
23pub const TOP_STOCK: usize = 5;
24pub const TOP_RESOLUTION: usize = 5;
25
26pub const MULLIGAN_CONFIRM_ID: usize = 0;
27pub const MULLIGAN_SELECT_BASE: usize = MULLIGAN_CONFIRM_ID + 1;
28pub const MULLIGAN_SELECT_COUNT: usize = MAX_HAND;
29
30pub const PASS_ACTION_ID: usize = MULLIGAN_SELECT_BASE + MULLIGAN_SELECT_COUNT;
31pub const CLOCK_HAND_BASE: usize = PASS_ACTION_ID + 1;
32pub const CLOCK_HAND_COUNT: usize = MAX_HAND;
33
34pub const MAIN_PLAY_CHAR_BASE: usize = CLOCK_HAND_BASE + CLOCK_HAND_COUNT;
35pub const MAIN_PLAY_CHAR_COUNT: usize = MAX_HAND * MAX_STAGE;
36pub const MAIN_PLAY_EVENT_BASE: usize = MAIN_PLAY_CHAR_BASE + MAIN_PLAY_CHAR_COUNT;
37pub const MAIN_PLAY_EVENT_COUNT: usize = MAX_HAND;
38pub const MAIN_MOVE_BASE: usize = MAIN_PLAY_EVENT_BASE + MAIN_PLAY_EVENT_COUNT;
39pub const MAIN_MOVE_COUNT: usize = MAX_STAGE * (MAX_STAGE - 1);
40
41pub const CLIMAX_PLAY_BASE: usize = MAIN_MOVE_BASE + MAIN_MOVE_COUNT;
42pub const CLIMAX_PLAY_COUNT: usize = MAX_HAND;
43
44pub const ATTACK_BASE: usize = CLIMAX_PLAY_BASE + CLIMAX_PLAY_COUNT;
45pub const ATTACK_COUNT: usize = ATTACK_SLOT_COUNT * 3;
46
47pub const LEVEL_UP_BASE: usize = ATTACK_BASE + ATTACK_COUNT;
48pub const LEVEL_UP_COUNT: usize = 7;
49
50pub const ENCORE_PAY_BASE: usize = LEVEL_UP_BASE + LEVEL_UP_COUNT;
51pub const ENCORE_PAY_COUNT: usize = MAX_STAGE;
52pub const ENCORE_DECLINE_BASE: usize = ENCORE_PAY_BASE + ENCORE_PAY_COUNT;
53pub const ENCORE_DECLINE_COUNT: usize = MAX_STAGE;
54
55pub const TRIGGER_ORDER_BASE: usize = ENCORE_DECLINE_BASE + ENCORE_DECLINE_COUNT;
56pub const TRIGGER_ORDER_COUNT: usize = 10;
57
58pub const CHOICE_BASE: usize = TRIGGER_ORDER_BASE + TRIGGER_ORDER_COUNT;
59pub const CHOICE_COUNT: usize = 16;
60pub const CHOICE_PREV_ID: usize = CHOICE_BASE + CHOICE_COUNT;
61pub const CHOICE_NEXT_ID: usize = CHOICE_PREV_ID + 1;
62
63pub const CONCEDE_ID: usize = CHOICE_NEXT_ID + 1;
64pub const ACTION_SPACE_SIZE: usize = CONCEDE_ID + 1;
65
66pub const OBS_HEADER_LEN: usize = 16;
67pub const OBS_REASON_LEN: usize = 8;
68pub const OBS_REASON_IN_MAIN: usize = 0;
69pub const OBS_REASON_IN_CLIMAX: usize = 1;
70pub const OBS_REASON_IN_ATTACK: usize = 2;
71pub const OBS_REASON_IN_COUNTER_WINDOW: usize = 3;
72pub const OBS_REASON_NO_STOCK: usize = 4;
73pub const OBS_REASON_NO_COLOR: usize = 5;
74pub const OBS_REASON_NO_HAND: usize = 6;
75pub const OBS_REASON_NO_TARGETS: usize = 7;
76pub const OBS_REVEAL_LEN: usize = REVEAL_HISTORY_LEN;
77pub const OBS_CONTEXT_LEN: usize = 4;
78pub const OBS_CONTEXT_PRIORITY_WINDOW: usize = 0;
79pub const OBS_CONTEXT_CHOICE_ACTIVE: usize = 1;
80pub const OBS_CONTEXT_STACK_NONEMPTY: usize = 2;
81pub const OBS_CONTEXT_ENCORE_PENDING: usize = 3;
82pub const PER_PLAYER_COUNTS: usize = 9;
83pub const PER_STAGE_SLOT: usize = 5;
84pub const PER_PLAYER_STAGE: usize = MAX_STAGE * PER_STAGE_SLOT;
85pub const PER_PLAYER_CLIMAX_TOP: usize = 1;
86pub const PER_PLAYER_LEVEL: usize = MAX_LEVEL;
87pub const PER_PLAYER_CLOCK_TOP: usize = TOP_CLOCK;
88pub const PER_PLAYER_WAITING_TOP: usize = TOP_WAITING_ROOM;
89pub const PER_PLAYER_RESOLUTION_TOP: usize = TOP_RESOLUTION;
90pub const PER_PLAYER_STOCK_TOP: usize = TOP_STOCK;
91pub const PER_PLAYER_HAND: usize = MAX_HAND;
92pub const PER_PLAYER_DECK: usize = MAX_DECK;
93pub const PER_PLAYER_BLOCK_LEN: usize = PER_PLAYER_COUNTS
94    + PER_PLAYER_STAGE
95    + PER_PLAYER_CLIMAX_TOP
96    + PER_PLAYER_LEVEL
97    + PER_PLAYER_CLOCK_TOP
98    + PER_PLAYER_WAITING_TOP
99    + PER_PLAYER_RESOLUTION_TOP
100    + PER_PLAYER_STOCK_TOP
101    + PER_PLAYER_HAND
102    + PER_PLAYER_DECK;
103pub const OBS_REASON_BASE: usize = OBS_HEADER_LEN + 2 * PER_PLAYER_BLOCK_LEN;
104pub const OBS_REVEAL_BASE: usize = OBS_REASON_BASE + OBS_REASON_LEN;
105pub const OBS_CONTEXT_BASE: usize = OBS_REVEAL_BASE + OBS_REVEAL_LEN;
106pub const OBS_LEN: usize = OBS_CONTEXT_BASE + OBS_CONTEXT_LEN;
107
108#[allow(clippy::too_many_arguments)]
109pub fn encode_observation(
110    state: &GameState,
111    db: &CardDb,
112    curriculum: &CurriculumConfig,
113    perspective: u8,
114    decision: Option<&Decision>,
115    last_action: Option<&ActionDesc>,
116    last_action_player: Option<u8>,
117    visibility: ObservationVisibility,
118    out: &mut [i32],
119) {
120    let mut slot_powers = [[0i32; MAX_STAGE]; 2];
121    compute_slot_powers_from_state(state, db, &mut slot_powers);
122    encode_observation_with_slot_power(
123        state,
124        db,
125        curriculum,
126        perspective,
127        decision,
128        last_action,
129        last_action_player,
130        visibility,
131        &slot_powers,
132        out,
133    );
134}
135
136#[allow(clippy::too_many_arguments)]
137pub(crate) fn encode_observation_with_slot_power(
138    state: &GameState,
139    db: &CardDb,
140    curriculum: &CurriculumConfig,
141    perspective: u8,
142    decision: Option<&Decision>,
143    last_action: Option<&ActionDesc>,
144    last_action_player: Option<u8>,
145    visibility: ObservationVisibility,
146    slot_powers: &[[i32; MAX_STAGE]; 2],
147    out: &mut [i32],
148) {
149    assert!(out.len() >= OBS_LEN);
150    out.fill(0);
151    let p0 = perspective as usize;
152    let p1 = 1 - p0;
153    out[0] = state.turn.active_player as i32;
154    out[1] = phase_to_i32(state.turn.phase);
155    out[2] = decision_kind_to_i32(decision.map(|d| d.kind));
156    out[3] = decision.map(|d| d.player as i32).unwrap_or(-1);
157    out[4] = terminal_to_i32(state.terminal);
158    let (last_kind, last_p1, last_p2) =
159        last_action_to_fields(last_action, last_action_player, perspective, visibility);
160    out[5] = last_kind;
161    out[6] = last_p1;
162    out[7] = last_p2;
163    if let Some(ctx) = &state.turn.attack {
164        out[8] = ctx.attacker_slot as i32;
165        out[9] = ctx.defender_slot.map(|s| s as i32).unwrap_or(-1);
166        out[10] = attack_type_to_i32(ctx.attack_type);
167        out[11] = ctx.damage;
168        out[12] = ctx.counter_power;
169    } else {
170        out[8] = -1;
171        out[9] = -1;
172        out[10] = -1;
173        out[11] = 0;
174        out[12] = 0;
175    }
176    out[13] = decision
177        .and_then(|d| d.focus_slot.map(|s| s as i32))
178        .unwrap_or(-1);
179    let choice_page = decision
180        .filter(|d| d.kind == DecisionKind::Choice)
181        .and(state.turn.choice.as_ref())
182        .map(|choice| (choice.page_start as i32, choice.total_candidates as i32));
183    if let Some((page_start, total)) = choice_page {
184        out[14] = page_start;
185        out[15] = total;
186    } else {
187        out[14] = -1;
188        out[15] = -1;
189    }
190
191    let mut offset = OBS_HEADER_LEN;
192    for (idx, player_index) in [p0, p1].iter().enumerate() {
193        let p = &state.players[*player_index];
194        out[offset] = p.level.len() as i32;
195        out[offset + 1] = p.clock.len() as i32;
196        out[offset + 2] = p.deck.len() as i32;
197        out[offset + 3] = p.hand.len() as i32;
198        out[offset + 4] = p.stock.len() as i32;
199        out[offset + 5] = p.waiting_room.len() as i32;
200        let memory_visible =
201            if visibility == ObservationVisibility::Public && !curriculum.memory_is_public {
202                *player_index == perspective as usize
203            } else {
204                true
205            };
206        out[offset + 6] = if memory_visible {
207            p.memory.len() as i32
208        } else {
209            0
210        };
211        out[offset + 7] = p.climax.len() as i32;
212        out[offset + 8] = p.resolution.len() as i32;
213        offset += PER_PLAYER_COUNTS;
214
215        for (slot, slot_state) in p.stage.iter().enumerate() {
216            let card_id = slot_state.card.map(|c| c.id).unwrap_or(0) as i32;
217            let status = if slot_state.card.is_some() {
218                status_to_i32(slot_state.status)
219            } else {
220                0
221            };
222            let has_attacked = if slot_state.has_attacked { 1 } else { 0 };
223            let (power, soul) = if let Some(card) = slot_state.card.and_then(|inst| db.get(inst.id))
224            {
225                let power = slot_powers[*player_index][slot];
226                let soul = card.soul as i32;
227                (power, soul)
228            } else {
229                (0, 0)
230            };
231            let base = offset + slot * PER_STAGE_SLOT;
232            out[base] = card_id;
233            out[base + 1] = status;
234            out[base + 2] = has_attacked;
235            out[base + 3] = power;
236            out[base + 4] = soul;
237        }
238        offset += PER_PLAYER_STAGE;
239
240        out[offset] = p.climax.last().map(|c| c.id).unwrap_or(0) as i32;
241        offset += PER_PLAYER_CLIMAX_TOP;
242
243        for i in 0..MAX_LEVEL {
244            out[offset + i] = p.level.get(i).map(|c| c.id).unwrap_or(0) as i32;
245        }
246        offset += PER_PLAYER_LEVEL;
247
248        for i in 0..TOP_CLOCK {
249            let idx = p.clock.len().saturating_sub(1 + i);
250            let value = if idx < p.clock.len() {
251                p.clock[idx].id as i32
252            } else {
253                0
254            };
255            out[offset + i] = value;
256        }
257        offset += PER_PLAYER_CLOCK_TOP;
258
259        for i in 0..TOP_WAITING_ROOM {
260            let idx = p.waiting_room.len().saturating_sub(1 + i);
261            let value = if idx < p.waiting_room.len() {
262                p.waiting_room[idx].id as i32
263            } else {
264                0
265            };
266            out[offset + i] = value;
267        }
268        offset += PER_PLAYER_WAITING_TOP;
269
270        for i in 0..TOP_RESOLUTION {
271            let idx = p.resolution.len().saturating_sub(1 + i);
272            let value = if idx < p.resolution.len() {
273                p.resolution[idx].id as i32
274            } else {
275                0
276            };
277            out[offset + i] = value;
278        }
279        offset += PER_PLAYER_RESOLUTION_TOP;
280
281        for i in 0..TOP_STOCK {
282            let value = if visibility == ObservationVisibility::Full {
283                let idx = p.stock.len().saturating_sub(1 + i);
284                if idx < p.stock.len() {
285                    p.stock[idx].id as i32
286                } else {
287                    0
288                }
289            } else {
290                -1
291            };
292            out[offset + i] = value;
293        }
294        offset += PER_PLAYER_STOCK_TOP;
295
296        for i in 0..MAX_HAND {
297            let value = if visibility == ObservationVisibility::Full || idx == 0 {
298                p.hand.get(i).map(|c| c.id).unwrap_or(0) as i32
299            } else {
300                -1
301            };
302            out[offset + i] = value;
303        }
304        offset += MAX_HAND;
305
306        for i in 0..MAX_DECK {
307            let value = if visibility == ObservationVisibility::Full {
308                if i < p.deck.len() {
309                    let deck_idx = p.deck.len() - 1 - i;
310                    p.deck[deck_idx].id as i32
311                } else {
312                    0
313                }
314            } else {
315                -1
316            };
317            out[offset + i] = value;
318        }
319        offset += MAX_DECK;
320    }
321
322    let reason_bits = compute_reason_bits(state, db, curriculum, perspective, decision);
323    let reason_base = OBS_REASON_BASE;
324    out[reason_base..reason_base + OBS_REASON_LEN].copy_from_slice(&reason_bits);
325
326    let reveal_base = OBS_REVEAL_BASE;
327    let reveal_slice = &mut out[reveal_base..reveal_base + OBS_REVEAL_LEN];
328    state.reveal_history[p0].write_chronological(reveal_slice);
329
330    let context_base = OBS_CONTEXT_BASE;
331    let context_bits = compute_context_bits(state);
332    out[context_base..context_base + OBS_CONTEXT_LEN].copy_from_slice(&context_bits);
333}
334
335fn compute_slot_powers_from_state(state: &GameState, db: &CardDb, out: &mut [[i32; MAX_STAGE]; 2]) {
336    let mut slot_card_ids = [[0u32; MAX_STAGE]; 2];
337    for (player, p) in state.players.iter().enumerate() {
338        for (slot, slot_state) in p.stage.iter().enumerate() {
339            slot_card_ids[player][slot] = slot_state.card.map(|c| c.id).unwrap_or(0);
340        }
341    }
342    let mut slot_power_mods = [[0i32; MAX_STAGE]; 2];
343    for modifier in &state.modifiers {
344        if modifier.kind != ModifierKind::Power {
345            continue;
346        }
347        let p = modifier.target_player as usize;
348        let s = modifier.target_slot as usize;
349        if p >= 2 || s >= MAX_STAGE {
350            continue;
351        }
352        if slot_card_ids[p][s] != modifier.target_card {
353            continue;
354        }
355        slot_power_mods[p][s] = slot_power_mods[p][s].saturating_add(modifier.magnitude);
356    }
357    for (player, p) in state.players.iter().enumerate() {
358        for (slot, slot_state) in p.stage.iter().enumerate() {
359            let power = if let Some(card) = slot_state.card.and_then(|inst| db.get(inst.id)) {
360                card.power
361                    + slot_state.power_mod_turn
362                    + slot_state.power_mod_battle
363                    + slot_power_mods[player][slot]
364            } else {
365                0
366            };
367            out[player][slot] = power;
368        }
369    }
370}
371
372fn compute_reason_bits(
373    state: &GameState,
374    db: &CardDb,
375    curriculum: &CurriculumConfig,
376    perspective: u8,
377    decision: Option<&Decision>,
378) -> [i32; OBS_REASON_LEN] {
379    let mut out = [0i32; OBS_REASON_LEN];
380    let decision = match decision {
381        Some(decision) if decision.player == perspective => decision,
382        _ => return out,
383    };
384    let in_main = decision.kind == DecisionKind::Main;
385    let in_climax = decision.kind == DecisionKind::Climax;
386    let in_attack = decision.kind == DecisionKind::AttackDeclaration;
387    let in_counter_window = state
388        .turn
389        .priority
390        .as_ref()
391        .map(|p| p.window == crate::state::TimingWindow::CounterWindow)
392        .unwrap_or(false);
393    out[OBS_REASON_IN_MAIN] = i32::from(in_main);
394    out[OBS_REASON_IN_CLIMAX] = i32::from(in_climax);
395    out[OBS_REASON_IN_ATTACK] = i32::from(in_attack);
396    out[OBS_REASON_IN_COUNTER_WINDOW] = i32::from(in_counter_window);
397
398    let p = &state.players[perspective as usize];
399    let mut any_candidate = false;
400    let mut stock_blocked = false;
401    let mut color_blocked = false;
402    if in_main || in_climax {
403        for card_inst in &p.hand {
404            let Some(card) = db.get(card_inst.id) else {
405                continue;
406            };
407            if !card_set_allowed(card, curriculum) {
408                continue;
409            }
410            if in_main {
411                match card.card_type {
412                    crate::db::CardType::Character => {
413                        if !curriculum.allow_character {
414                            continue;
415                        }
416                    }
417                    crate::db::CardType::Event => {
418                        if !curriculum.allow_event {
419                            continue;
420                        }
421                    }
422                    _ => continue,
423                }
424            } else if in_climax {
425                if card.card_type != crate::db::CardType::Climax || !curriculum.allow_climax {
426                    continue;
427                }
428                if !curriculum.enable_climax_phase {
429                    continue;
430                }
431            }
432            if !meets_level_requirement(card, p.level.len()) {
433                continue;
434            }
435            any_candidate = true;
436            if !meets_cost_requirement(card, p, curriculum) {
437                stock_blocked = true;
438            }
439            if !meets_color_requirement(card, p, db, curriculum) {
440                color_blocked = true;
441            }
442        }
443    }
444    if in_main || in_climax {
445        out[OBS_REASON_NO_HAND] = i32::from(!any_candidate);
446        out[OBS_REASON_NO_STOCK] = i32::from(stock_blocked);
447        out[OBS_REASON_NO_COLOR] = i32::from(color_blocked);
448    }
449
450    let no_targets = decision.kind == DecisionKind::Choice
451        && state
452            .turn
453            .choice
454            .as_ref()
455            .map(|choice| {
456                choice
457                    .options
458                    .iter()
459                    .all(|opt| opt.zone == crate::state::ChoiceZone::Skip)
460            })
461            .unwrap_or(true);
462    out[OBS_REASON_NO_TARGETS] = i32::from(no_targets);
463
464    out
465}
466
467fn compute_context_bits(state: &GameState) -> [i32; OBS_CONTEXT_LEN] {
468    let mut out = [0i32; OBS_CONTEXT_LEN];
469    out[OBS_CONTEXT_PRIORITY_WINDOW] = i32::from(state.turn.priority.is_some());
470    out[OBS_CONTEXT_CHOICE_ACTIVE] = i32::from(state.turn.choice.is_some());
471    out[OBS_CONTEXT_STACK_NONEMPTY] = i32::from(!state.turn.stack.is_empty());
472    out[OBS_CONTEXT_ENCORE_PENDING] = i32::from(!state.turn.encore_queue.is_empty());
473    out
474}
475
476fn card_set_allowed(card: &crate::db::CardStatic, curriculum: &CurriculumConfig) -> bool {
477    if let Some(set) = curriculum.allowed_card_sets_cache.as_ref() {
478        match &card.card_set {
479            Some(set_id) => set.contains(set_id),
480            None => false,
481        }
482    } else if curriculum.allowed_card_sets.is_empty() {
483        true
484    } else {
485        card.card_set
486            .as_ref()
487            .map(|s| curriculum.allowed_card_sets.iter().any(|a| a == s))
488            .unwrap_or(false)
489    }
490}
491
492fn meets_level_requirement(card: &crate::db::CardStatic, level_count: usize) -> bool {
493    card.level as usize <= level_count
494}
495
496fn meets_cost_requirement(
497    card: &crate::db::CardStatic,
498    player: &crate::state::PlayerState,
499    curriculum: &CurriculumConfig,
500) -> bool {
501    if !curriculum.enforce_cost_requirement {
502        return true;
503    }
504    player.stock.len() >= card.cost as usize
505}
506
507fn meets_color_requirement(
508    card: &crate::db::CardStatic,
509    player: &crate::state::PlayerState,
510    db: &CardDb,
511    curriculum: &CurriculumConfig,
512) -> bool {
513    if !curriculum.enforce_color_requirement {
514        return true;
515    }
516    if card.level == 0 || card.color == crate::db::CardColor::Colorless {
517        return true;
518    }
519    for card_id in player.level.iter().chain(player.clock.iter()) {
520        if let Some(c) = db.get(card_id.id) {
521            if c.color == card.color {
522                return true;
523            }
524        }
525    }
526    false
527}
528
529fn phase_to_i32(phase: Phase) -> i32 {
530    match phase {
531        Phase::Mulligan => 0,
532        Phase::Stand => 1,
533        Phase::Draw => 2,
534        Phase::Clock => 3,
535        Phase::Main => 4,
536        Phase::Climax => 5,
537        Phase::Attack => 6,
538        Phase::End => 7,
539    }
540}
541
542fn decision_kind_to_i32(kind: Option<DecisionKind>) -> i32 {
543    match kind {
544        Some(DecisionKind::Mulligan) => 0,
545        Some(DecisionKind::Clock) => 1,
546        Some(DecisionKind::Main) => 2,
547        Some(DecisionKind::Climax) => 3,
548        Some(DecisionKind::AttackDeclaration) => 4,
549        Some(DecisionKind::LevelUp) => 5,
550        Some(DecisionKind::Encore) => 6,
551        Some(DecisionKind::TriggerOrder) => 7,
552        Some(DecisionKind::Choice) => 8,
553        None => -1,
554    }
555}
556
557fn attack_type_to_i32(attack_type: AttackType) -> i32 {
558    match attack_type {
559        AttackType::Frontal => 0,
560        AttackType::Side => 1,
561        AttackType::Direct => 2,
562    }
563}
564
565fn status_to_i32(status: StageStatus) -> i32 {
566    match status {
567        StageStatus::Stand => 1,
568        StageStatus::Rest => 2,
569        StageStatus::Reverse => 3,
570    }
571}
572
573fn terminal_to_i32(term: Option<TerminalResult>) -> i32 {
574    match term {
575        None => 0,
576        Some(TerminalResult::Win { winner }) => {
577            if winner == 0 {
578                1
579            } else {
580                2
581            }
582        }
583        Some(TerminalResult::Draw) => 3,
584        Some(TerminalResult::Timeout) => 4,
585    }
586}
587
588fn last_action_to_fields(
589    action: Option<&ActionDesc>,
590    actor: Option<u8>,
591    perspective: u8,
592    visibility: ObservationVisibility,
593) -> (i32, i32, i32) {
594    let mask = visibility == ObservationVisibility::Public
595        && actor.map(|p| p != perspective).unwrap_or(false);
596    match action {
597        None => (0, -1, -1),
598        Some(ActionDesc::MulliganConfirm) => (1, -1, -1),
599        Some(ActionDesc::MulliganSelect { hand_index }) => {
600            let idx = if mask { -1 } else { *hand_index as i32 };
601            (2, idx, -1)
602        }
603        Some(ActionDesc::Pass) => (3, -1, -1),
604        Some(ActionDesc::Clock { hand_index }) => {
605            let idx = if mask { -1 } else { *hand_index as i32 };
606            (4, idx, -1)
607        }
608        Some(ActionDesc::MainPlayCharacter {
609            hand_index,
610            stage_slot,
611        }) => {
612            let idx = if mask { -1 } else { *hand_index as i32 };
613            (6, idx, *stage_slot as i32)
614        }
615        Some(ActionDesc::MainPlayEvent { hand_index }) => {
616            let idx = if mask { -1 } else { *hand_index as i32 };
617            (7, idx, -1)
618        }
619        Some(ActionDesc::MainMove { from_slot, to_slot }) => {
620            (8, *from_slot as i32, *to_slot as i32)
621        }
622        Some(ActionDesc::MainActivateAbility {
623            slot,
624            ability_index,
625        }) => (9, *slot as i32, *ability_index as i32),
626        Some(ActionDesc::ClimaxPlay { hand_index }) => {
627            let idx = if mask { -1 } else { *hand_index as i32 };
628            (11, idx, -1)
629        }
630        Some(ActionDesc::Attack { slot, attack_type }) => {
631            (13, *slot as i32, attack_type_to_i32(*attack_type))
632        }
633        Some(ActionDesc::CounterPlay { hand_index }) => {
634            let idx = if mask { -1 } else { *hand_index as i32 };
635            (15, idx, -1)
636        }
637        Some(ActionDesc::LevelUp { index }) => (16, *index as i32, -1),
638        Some(ActionDesc::EncorePay { slot }) => (17, *slot as i32, -1),
639        Some(ActionDesc::EncoreDecline { slot }) => (22, *slot as i32, -1),
640        Some(ActionDesc::TriggerOrder { index }) => (18, *index as i32, -1),
641        Some(ActionDesc::ChoiceSelect { index }) => {
642            let idx = if mask { -1 } else { *index as i32 };
643            (19, idx, -1)
644        }
645        Some(ActionDesc::ChoicePrevPage) => (20, -1, -1),
646        Some(ActionDesc::ChoiceNextPage) => (21, -1, -1),
647        Some(ActionDesc::Concede) => (23, -1, -1),
648    }
649}
650
651pub fn action_id_for(action: &ActionDesc) -> Option<usize> {
652    match action {
653        ActionDesc::MulliganConfirm => Some(MULLIGAN_CONFIRM_ID),
654        ActionDesc::MulliganSelect { hand_index } => {
655            let hi = *hand_index as usize;
656            if hi < MULLIGAN_SELECT_COUNT {
657                Some(MULLIGAN_SELECT_BASE + hi)
658            } else {
659                None
660            }
661        }
662        ActionDesc::Pass => Some(PASS_ACTION_ID),
663        ActionDesc::Clock { hand_index } => {
664            let hi = *hand_index as usize;
665            if hi < MAX_HAND {
666                Some(CLOCK_HAND_BASE + hi)
667            } else {
668                None
669            }
670        }
671        ActionDesc::MainPlayCharacter {
672            hand_index,
673            stage_slot,
674        } => {
675            let hi = *hand_index as usize;
676            let ss = *stage_slot as usize;
677            if hi < MAX_HAND && ss < MAX_STAGE {
678                Some(MAIN_PLAY_CHAR_BASE + hi * MAX_STAGE + ss)
679            } else {
680                None
681            }
682        }
683        ActionDesc::MainPlayEvent { hand_index } => {
684            let hi = *hand_index as usize;
685            if hi < MAX_HAND {
686                Some(MAIN_PLAY_EVENT_BASE + hi)
687            } else {
688                None
689            }
690        }
691        ActionDesc::MainMove { from_slot, to_slot } => {
692            let fs = *from_slot as usize;
693            let ts = *to_slot as usize;
694            if fs < MAX_STAGE && ts < MAX_STAGE && fs != ts {
695                let to_index = if ts < fs { ts } else { ts - 1 };
696                Some(MAIN_MOVE_BASE + fs * (MAX_STAGE - 1) + to_index)
697            } else {
698                None
699            }
700        }
701        ActionDesc::MainActivateAbility {
702            slot,
703            ability_index,
704        } => {
705            let _ = (slot, ability_index);
706            None
707        }
708        ActionDesc::ClimaxPlay { hand_index } => {
709            let hi = *hand_index as usize;
710            if hi < MAX_HAND {
711                Some(CLIMAX_PLAY_BASE + hi)
712            } else {
713                None
714            }
715        }
716        ActionDesc::Attack { slot, attack_type } => {
717            let s = *slot as usize;
718            let t = attack_type_to_i32(*attack_type) as usize;
719            if s < ATTACK_SLOT_COUNT && t < 3 {
720                Some(ATTACK_BASE + s * 3 + t)
721            } else {
722                None
723            }
724        }
725        ActionDesc::CounterPlay { hand_index } => {
726            let _ = hand_index;
727            None
728        }
729        ActionDesc::LevelUp { index } => {
730            let idx = *index as usize;
731            if idx < LEVEL_UP_COUNT {
732                Some(LEVEL_UP_BASE + idx)
733            } else {
734                None
735            }
736        }
737        ActionDesc::EncorePay { slot } => {
738            let s = *slot as usize;
739            if s < ENCORE_PAY_COUNT {
740                Some(ENCORE_PAY_BASE + s)
741            } else {
742                None
743            }
744        }
745        ActionDesc::EncoreDecline { slot } => {
746            let s = *slot as usize;
747            if s < ENCORE_DECLINE_COUNT {
748                Some(ENCORE_DECLINE_BASE + s)
749            } else {
750                None
751            }
752        }
753        ActionDesc::TriggerOrder { index } => {
754            let idx = *index as usize;
755            if idx < TRIGGER_ORDER_COUNT {
756                Some(TRIGGER_ORDER_BASE + idx)
757            } else {
758                None
759            }
760        }
761        ActionDesc::ChoiceSelect { index } => {
762            let idx = *index as usize;
763            if idx < CHOICE_COUNT {
764                Some(CHOICE_BASE + idx)
765            } else {
766                None
767            }
768        }
769        ActionDesc::ChoicePrevPage => Some(CHOICE_PREV_ID),
770        ActionDesc::ChoiceNextPage => Some(CHOICE_NEXT_ID),
771        ActionDesc::Concede => Some(CONCEDE_ID),
772    }
773}
774
775pub fn fill_action_mask(
776    actions: &[ActionDesc],
777    mask: &mut [u8],
778    lookup: &mut [Option<ActionDesc>],
779) {
780    mask.fill(0);
781    for slot in lookup.iter_mut() {
782        *slot = None;
783    }
784    for action in actions {
785        if let Some(id) = action_id_for(action) {
786            if id < ACTION_SPACE_SIZE {
787                mask[id] = 1;
788                lookup[id] = Some(action.clone());
789            }
790        }
791    }
792}
793
794pub fn build_action_mask(actions: &[ActionDesc]) -> (Vec<u8>, Vec<Option<ActionDesc>>) {
795    let mut mask = vec![0u8; ACTION_SPACE_SIZE];
796    let mut lookup = vec![None; ACTION_SPACE_SIZE];
797    fill_action_mask(actions, &mut mask, &mut lookup);
798    (mask, lookup)
799}