weiss_core/encode/
observation.rs

1use crate::config::{CurriculumConfig, ObservationVisibility};
2use crate::db::CardDb;
3use crate::legal::hand_play_requirements::{
4    card_set_allowed, meets_color_requirement, meets_cost_requirement, meets_level_requirement,
5};
6use crate::legal::{ActionDesc, Decision, DecisionKind};
7use crate::state::{AttackType, GameState, ModifierKind, Phase, StageStatus, TerminalResult};
8
9use super::constants::*;
10
11/// Encode a full observation into a fixed-length buffer.
12#[allow(clippy::too_many_arguments)]
13pub fn encode_observation(
14    state: &GameState,
15    db: &CardDb,
16    curriculum: &CurriculumConfig,
17    perspective: u8,
18    decision: Option<&Decision>,
19    last_action: Option<&ActionDesc>,
20    last_action_player: Option<u8>,
21    visibility: ObservationVisibility,
22    out: &mut [i32],
23) {
24    let mut slot_powers = [[0i32; MAX_STAGE]; 2];
25    compute_slot_powers_from_state(state, db, &mut slot_powers);
26    encode_observation_with_slot_power(
27        state,
28        db,
29        curriculum,
30        perspective,
31        decision,
32        last_action,
33        last_action_player,
34        visibility,
35        &slot_powers,
36        out,
37    );
38}
39
40#[allow(clippy::too_many_arguments)]
41/// Encode the observation header section (`OBS_HEADER_LEN` values).
42pub(crate) fn encode_obs_header(
43    state: &GameState,
44    perspective: u8,
45    decision: Option<&Decision>,
46    last_action: Option<&ActionDesc>,
47    last_action_player: Option<u8>,
48    visibility: ObservationVisibility,
49    out: &mut [i32],
50) {
51    assert!(out.len() >= OBS_HEADER_LEN);
52    out[0] = state.turn.active_player as i32;
53    out[1] = phase_to_i32(state.turn.phase);
54    out[2] = decision_kind_to_i32(decision.map(|d| d.kind));
55    out[3] = decision.map(|d| d.player as i32).unwrap_or(-1);
56    out[4] = terminal_to_i32(state.terminal);
57    let (last_kind, last_p1, last_p2) =
58        last_action_to_fields(last_action, last_action_player, perspective, visibility);
59    out[5] = last_kind;
60    out[6] = last_p1;
61    out[7] = last_p2;
62    if let Some(ctx) = &state.turn.attack {
63        out[8] = ctx.attacker_slot as i32;
64        out[9] = ctx.defender_slot.map(|s| s as i32).unwrap_or(-1);
65        out[10] = attack_type_to_i32(ctx.attack_type);
66        out[11] = ctx.damage;
67        out[12] = ctx.counter_power;
68    } else {
69        out[8] = -1;
70        out[9] = -1;
71        out[10] = -1;
72        out[11] = 0;
73        out[12] = 0;
74    }
75    out[13] = decision
76        .and_then(|d| d.focus_slot.map(|s| s as i32))
77        .unwrap_or(-1);
78    let choice_page = decision
79        .filter(|d| d.kind == DecisionKind::Choice)
80        .and(state.turn.choice.as_ref())
81        .map(|choice| (choice.page_start as i32, choice.total_candidates as i32));
82    if let Some((page_start, total)) = choice_page {
83        out[14] = page_start;
84        out[15] = total;
85    } else {
86        out[14] = -1;
87        out[15] = -1;
88    }
89}
90
91#[allow(clippy::too_many_arguments)]
92/// Encode one player's observation block into the full observation buffer.
93///
94/// `player_index` selects the source player; block placement (`self` vs `opp`)
95/// is derived from `perspective`.
96pub(crate) fn encode_obs_player_block(
97    state: &GameState,
98    db: &CardDb,
99    curriculum: &CurriculumConfig,
100    perspective: u8,
101    player_index: u8,
102    visibility: ObservationVisibility,
103    slot_powers: &[[i32; MAX_STAGE]; 2],
104    out: &mut [i32],
105) {
106    assert!(out.len() >= OBS_HEADER_LEN + 2 * PER_PLAYER_BLOCK_LEN);
107    let p = player_index as usize;
108    let block_index = if p == perspective as usize { 0 } else { 1 };
109    let is_self = block_index == 0;
110    let memory_visible =
111        visibility == ObservationVisibility::Full || curriculum.memory_is_public || is_self;
112    let hand_visible = visibility == ObservationVisibility::Full || is_self;
113    let stock_visible = visibility == ObservationVisibility::Full || is_self;
114    let deck_visible = visibility == ObservationVisibility::Full || is_self;
115    let hand_count_visible = private_count_visible(visibility, curriculum, is_self);
116    let stock_count_visible = private_count_visible(visibility, curriculum, is_self);
117    let base = OBS_HEADER_LEN + block_index * PER_PLAYER_BLOCK_LEN;
118    let block = &mut out[base..base + PER_PLAYER_BLOCK_LEN];
119    encode_obs_player_block_into(
120        state,
121        db,
122        player_index,
123        memory_visible,
124        hand_count_visible,
125        hand_visible,
126        stock_count_visible,
127        stock_visible,
128        deck_visible,
129        slot_powers,
130        block,
131    );
132}
133
134#[allow(clippy::too_many_arguments)]
135/// Encode one player's observation block into `out` only.
136///
137/// This variant accepts explicit visibility booleans and is used by the
138/// incremental observation cache path.
139pub(crate) fn encode_obs_player_block_into(
140    state: &GameState,
141    db: &CardDb,
142    player_index: u8,
143    memory_visible: bool,
144    hand_count_visible: bool,
145    hand_visible: bool,
146    stock_count_visible: bool,
147    stock_visible: bool,
148    deck_visible: bool,
149    slot_powers: &[[i32; MAX_STAGE]; 2],
150    out: &mut [i32],
151) {
152    assert!(out.len() >= PER_PLAYER_BLOCK_LEN);
153    let p = player_index as usize;
154    let mut offset = 0;
155    let player = &state.players[p];
156    let mut slot_card_ids = [0u32; MAX_STAGE];
157    let mut slot_soul_mods = [0i32; MAX_STAGE];
158    let mut slot_side_attack_allowed = [0i32; MAX_STAGE];
159    for (slot, slot_state) in player.stage.iter().enumerate() {
160        let card_id = slot_state.card.map(|c| c.id).unwrap_or(0);
161        slot_card_ids[slot] = card_id;
162        slot_side_attack_allowed[slot] = i32::from(card_id != 0);
163    }
164    let use_derived_attack = state.turn.derived_attack.is_some();
165    if !state.modifiers.is_empty() {
166        for modifier in &state.modifiers {
167            if modifier.target_player as usize != p {
168                continue;
169            }
170            let slot = modifier.target_slot as usize;
171            if slot >= MAX_STAGE {
172                continue;
173            }
174            let card_id = slot_card_ids[slot];
175            if card_id == 0 || modifier.target_card != card_id {
176                continue;
177            }
178            match modifier.kind {
179                ModifierKind::Soul => {
180                    slot_soul_mods[slot] = slot_soul_mods[slot].saturating_add(modifier.magnitude);
181                }
182                ModifierKind::CannotSideAttack
183                    if !use_derived_attack && modifier.magnitude != 0 =>
184                {
185                    slot_side_attack_allowed[slot] = 0;
186                }
187                _ => {}
188            }
189        }
190    }
191    if let Some(derived) = state.turn.derived_attack.as_ref() {
192        for (slot, card_id) in slot_card_ids.iter().enumerate() {
193            if *card_id == 0 {
194                continue;
195            }
196            slot_side_attack_allowed[slot] =
197                i32::from(!derived.per_player[p][slot].cannot_side_attack);
198        }
199    }
200    out[offset] = player.level.len() as i32;
201    out[offset + 1] = player.clock.len() as i32;
202    out[offset + 2] = player.deck.len() as i32;
203    out[offset + 3] = if hand_count_visible {
204        player.hand.len() as i32
205    } else {
206        0
207    };
208    out[offset + 4] = if stock_count_visible {
209        player.stock.len() as i32
210    } else {
211        0
212    };
213    out[offset + 5] = player.waiting_room.len() as i32;
214    out[offset + 6] = if memory_visible {
215        player.memory.len() as i32
216    } else {
217        0
218    };
219    out[offset + 7] = player.climax.len() as i32;
220    out[offset + 8] = player.resolution.len() as i32;
221    offset += PER_PLAYER_COUNTS;
222
223    for (slot, slot_state) in player.stage.iter().enumerate() {
224        let card_id = slot_state.card.map(|c| c.id).unwrap_or(0) as i32;
225        let status = if slot_state.card.is_some() {
226            status_to_i32(slot_state.status)
227        } else {
228            0
229        };
230        let has_attacked = if slot_state.has_attacked { 1 } else { 0 };
231        let (power, soul, effective_soul, side_attack_allowed) =
232            if let Some(card_inst) = slot_state.card {
233                let power = slot_powers[p][slot];
234                let soul = db.soul_by_id(card_inst.id) as i32;
235                let effective_soul = soul.saturating_add(slot_soul_mods[slot]).max(0);
236                let side_attack_allowed = slot_side_attack_allowed[slot];
237                (power, soul, effective_soul, side_attack_allowed)
238            } else {
239                (0, 0, 0, 0)
240            };
241        let base = offset + slot * PER_STAGE_SLOT;
242        out[base] = card_id;
243        out[base + 1] = status;
244        out[base + 2] = has_attacked;
245        out[base + 3] = power;
246        out[base + 4] = soul;
247        out[base + 5] = effective_soul;
248        out[base + 6] = side_attack_allowed;
249    }
250    offset += PER_PLAYER_STAGE;
251
252    out[offset] = player.climax.last().map(|c| c.id).unwrap_or(0) as i32;
253    offset += PER_PLAYER_CLIMAX_TOP;
254
255    for i in 0..MAX_LEVEL {
256        out[offset + i] = player.level.get(i).map(|c| c.id).unwrap_or(0) as i32;
257    }
258    offset += PER_PLAYER_LEVEL;
259
260    for i in 0..TOP_CLOCK {
261        if i < player.clock.len() {
262            let idx = player.clock.len() - 1 - i;
263            out[offset + i] = player.clock[idx].id as i32;
264        } else {
265            out[offset + i] = 0;
266        }
267    }
268    offset += PER_PLAYER_CLOCK_TOP;
269
270    for i in 0..TOP_WAITING_ROOM {
271        if i < player.waiting_room.len() {
272            let idx = player.waiting_room.len() - 1 - i;
273            out[offset + i] = player.waiting_room[idx].id as i32;
274        } else {
275            out[offset + i] = 0;
276        }
277    }
278    offset += PER_PLAYER_WAITING_TOP;
279
280    for i in 0..TOP_RESOLUTION {
281        if i < player.resolution.len() {
282            let idx = player.resolution.len() - 1 - i;
283            out[offset + i] = player.resolution[idx].id as i32;
284        } else {
285            out[offset + i] = 0;
286        }
287    }
288    offset += PER_PLAYER_RESOLUTION_TOP;
289
290    if stock_visible {
291        for i in 0..TOP_STOCK {
292            if i < player.stock.len() {
293                let idx = player.stock.len() - 1 - i;
294                out[offset + i] = player.stock[idx].id as i32;
295            } else {
296                out[offset + i] = 0;
297            }
298        }
299    } else {
300        out[offset..offset + TOP_STOCK].fill(-1);
301    }
302    offset += PER_PLAYER_STOCK_TOP;
303
304    if hand_visible {
305        for i in 0..MAX_HAND {
306            out[offset + i] = player.hand.get(i).map(|c| c.id).unwrap_or(0) as i32;
307        }
308    } else {
309        out[offset..offset + MAX_HAND].fill(-1);
310    }
311    offset += MAX_HAND;
312
313    if deck_visible {
314        for i in 0..MAX_DECK {
315            out[offset + i] = if i < player.deck.len() {
316                let deck_idx = player.deck.len() - 1 - i;
317                player.deck[deck_idx].id as i32
318            } else {
319                0
320            };
321        }
322    } else {
323        out[offset..offset + MAX_DECK].fill(-1);
324    }
325}
326
327fn private_count_visible(
328    visibility: ObservationVisibility,
329    curriculum: &CurriculumConfig,
330    is_self: bool,
331) -> bool {
332    visibility == ObservationVisibility::Full
333        || is_self
334        || curriculum.reveal_opponent_hand_stock_counts
335}
336
337#[allow(clippy::too_many_arguments)]
338/// Encode the reason-bit section describing current decision constraints.
339pub(crate) fn encode_obs_reason(
340    state: &GameState,
341    db: &CardDb,
342    curriculum: &CurriculumConfig,
343    perspective: u8,
344    decision: Option<&Decision>,
345    out: &mut [i32],
346) {
347    assert!(out.len() >= OBS_REASON_BASE + OBS_REASON_LEN);
348    let reason_bits = compute_reason_bits(state, db, curriculum, perspective, decision);
349    let reason_base = OBS_REASON_BASE;
350    out[reason_base..reason_base + OBS_REASON_LEN].copy_from_slice(&reason_bits);
351}
352
353/// Encode reveal-history features for `perspective`.
354pub(crate) fn encode_obs_reveal(state: &GameState, perspective: u8, out: &mut [i32]) {
355    assert!(out.len() >= OBS_REVEAL_BASE + OBS_REVEAL_LEN);
356    let reveal_base = OBS_REVEAL_BASE;
357    let reveal_slice = &mut out[reveal_base..reveal_base + OBS_REVEAL_LEN];
358    state.reveal_history[perspective as usize].write_chronological(reveal_slice);
359}
360
361/// Encode compact context bits (priority/choice/stack/encore state).
362pub(crate) fn encode_obs_context(state: &GameState, out: &mut [i32]) {
363    assert!(out.len() >= OBS_CONTEXT_BASE + OBS_CONTEXT_LEN);
364    let context_base = OBS_CONTEXT_BASE;
365    let context_bits = compute_context_bits(state);
366    out[context_base..context_base + OBS_CONTEXT_LEN].copy_from_slice(&context_bits);
367}
368
369#[allow(clippy::too_many_arguments)]
370/// Encode a full observation using caller-provided per-slot power values.
371///
372/// Supplying `slot_powers` allows callers to reuse cached power calculations
373/// across repeated observation builds.
374pub(crate) fn encode_observation_with_slot_power(
375    state: &GameState,
376    db: &CardDb,
377    curriculum: &CurriculumConfig,
378    perspective: u8,
379    decision: Option<&Decision>,
380    last_action: Option<&ActionDesc>,
381    last_action_player: Option<u8>,
382    visibility: ObservationVisibility,
383    slot_powers: &[[i32; MAX_STAGE]; 2],
384    out: &mut [i32],
385) {
386    assert!(out.len() >= OBS_LEN);
387    encode_obs_header(
388        state,
389        perspective,
390        decision,
391        last_action,
392        last_action_player,
393        visibility,
394        out,
395    );
396    encode_obs_player_block(
397        state,
398        db,
399        curriculum,
400        perspective,
401        perspective,
402        visibility,
403        slot_powers,
404        out,
405    );
406    debug_assert!(perspective <= 1, "invalid perspective");
407    let other = match perspective {
408        0 => 1,
409        1 => 0,
410        _ => 0,
411    };
412    encode_obs_player_block(
413        state,
414        db,
415        curriculum,
416        perspective,
417        other,
418        visibility,
419        slot_powers,
420        out,
421    );
422    encode_obs_reason(state, db, curriculum, perspective, decision, out);
423    encode_obs_reveal(state, perspective, out);
424    encode_obs_context(state, out);
425}
426
427fn compute_slot_powers_from_state(state: &GameState, db: &CardDb, out: &mut [[i32; MAX_STAGE]; 2]) {
428    let mut has_power_mods = false;
429    for modifier in &state.modifiers {
430        if modifier.kind == ModifierKind::Power {
431            has_power_mods = true;
432            break;
433        }
434    }
435    if !has_power_mods {
436        for (player, p) in state.players.iter().enumerate() {
437            for (slot, slot_state) in p.stage.iter().enumerate() {
438                let power = if let Some(card_inst) = slot_state.card {
439                    db.power_by_id(card_inst.id)
440                        + slot_state.power_mod_turn
441                        + slot_state.power_mod_battle
442                } else {
443                    0
444                };
445                out[player][slot] = power;
446            }
447        }
448        return;
449    }
450    let mut slot_card_ids = [[0u32; MAX_STAGE]; 2];
451    for (player, p) in state.players.iter().enumerate() {
452        for (slot, slot_state) in p.stage.iter().enumerate() {
453            slot_card_ids[player][slot] = slot_state.card.map(|c| c.id).unwrap_or(0);
454        }
455    }
456    let mut slot_power_mods = [[0i32; MAX_STAGE]; 2];
457    for modifier in &state.modifiers {
458        if modifier.kind != ModifierKind::Power {
459            continue;
460        }
461        let p = modifier.target_player as usize;
462        let s = modifier.target_slot as usize;
463        if p >= 2 || s >= MAX_STAGE {
464            continue;
465        }
466        if slot_card_ids[p][s] != modifier.target_card {
467            continue;
468        }
469        slot_power_mods[p][s] = slot_power_mods[p][s].saturating_add(modifier.magnitude);
470    }
471    for (player, p) in state.players.iter().enumerate() {
472        for (slot, slot_state) in p.stage.iter().enumerate() {
473            let power = if let Some(card_inst) = slot_state.card {
474                db.power_by_id(card_inst.id)
475                    + slot_state.power_mod_turn
476                    + slot_state.power_mod_battle
477                    + slot_power_mods[player][slot]
478            } else {
479                0
480            };
481            out[player][slot] = power;
482        }
483    }
484}
485
486fn compute_reason_bits(
487    state: &GameState,
488    db: &CardDb,
489    curriculum: &CurriculumConfig,
490    perspective: u8,
491    decision: Option<&Decision>,
492) -> [i32; OBS_REASON_LEN] {
493    let mut out = [0i32; OBS_REASON_LEN];
494    let decision = match decision {
495        Some(decision) if decision.player == perspective => decision,
496        _ => return out,
497    };
498    let in_main = decision.kind == DecisionKind::Main;
499    let in_climax = decision.kind == DecisionKind::Climax;
500    let in_attack = decision.kind == DecisionKind::AttackDeclaration;
501    let in_counter_window = state
502        .turn
503        .priority
504        .as_ref()
505        .map(|p| p.window == crate::state::TimingWindow::CounterWindow)
506        .unwrap_or(false);
507    out[OBS_REASON_IN_MAIN] = i32::from(in_main);
508    out[OBS_REASON_IN_CLIMAX] = i32::from(in_climax);
509    out[OBS_REASON_IN_ATTACK] = i32::from(in_attack);
510    out[OBS_REASON_IN_COUNTER_WINDOW] = i32::from(in_counter_window);
511
512    let p = &state.players[perspective as usize];
513    let mut any_candidate = false;
514    let mut stock_blocked = false;
515    let mut color_blocked = false;
516    if in_main || in_climax {
517        for card_inst in &p.hand {
518            let Some(card) = db.get(card_inst.id) else {
519                continue;
520            };
521            if !card_set_allowed(card, curriculum, None) {
522                continue;
523            }
524            if in_main {
525                match card.card_type {
526                    crate::db::CardType::Character => {
527                        if !curriculum.allow_character {
528                            continue;
529                        }
530                    }
531                    crate::db::CardType::Event => {
532                        if !curriculum.allow_event {
533                            continue;
534                        }
535                    }
536                    _ => continue,
537                }
538            } else if in_climax {
539                if card.card_type != crate::db::CardType::Climax || !curriculum.allow_climax {
540                    continue;
541                }
542                if !curriculum.enable_climax_phase {
543                    continue;
544                }
545            }
546            if !meets_level_requirement(card, p.level.len(), 0) {
547                continue;
548            }
549            any_candidate = true;
550            if !meets_cost_requirement(card, p.stock.len(), curriculum.enforce_cost_requirement) {
551                stock_blocked = true;
552            }
553            if !meets_color_requirement(card, p, db, curriculum.enforce_color_requirement, false) {
554                color_blocked = true;
555            }
556        }
557    }
558    if in_main || in_climax {
559        out[OBS_REASON_NO_HAND] = i32::from(!any_candidate);
560        out[OBS_REASON_NO_STOCK] = i32::from(stock_blocked);
561        out[OBS_REASON_NO_COLOR] = i32::from(color_blocked);
562    }
563
564    let no_targets = decision.kind == DecisionKind::Choice
565        && state
566            .turn
567            .choice
568            .as_ref()
569            .map(|choice| {
570                choice
571                    .options
572                    .iter()
573                    .all(|opt| opt.zone == crate::state::ChoiceZone::Skip)
574            })
575            .unwrap_or(true);
576    out[OBS_REASON_NO_TARGETS] = i32::from(no_targets);
577
578    out
579}
580
581fn compute_context_bits(state: &GameState) -> [i32; OBS_CONTEXT_LEN] {
582    let mut out = [0i32; OBS_CONTEXT_LEN];
583    out[OBS_CONTEXT_PRIORITY_WINDOW] = i32::from(state.turn.priority.is_some());
584    out[OBS_CONTEXT_CHOICE_ACTIVE] = i32::from(state.turn.choice.is_some());
585    out[OBS_CONTEXT_STACK_NONEMPTY] = i32::from(!state.turn.stack.is_empty());
586    out[OBS_CONTEXT_ENCORE_PENDING] = i32::from(!state.turn.encore_queue.is_empty());
587    out
588}
589
590fn phase_to_i32(phase: Phase) -> i32 {
591    match phase {
592        Phase::Mulligan => 0,
593        Phase::Stand => 1,
594        Phase::Draw => 2,
595        Phase::Clock => 3,
596        Phase::Main => 4,
597        Phase::Climax => 5,
598        Phase::Attack => 6,
599        Phase::End => 7,
600    }
601}
602
603fn decision_kind_to_i32(kind: Option<DecisionKind>) -> i32 {
604    match kind {
605        Some(DecisionKind::Mulligan) => 0,
606        Some(DecisionKind::Clock) => 1,
607        Some(DecisionKind::Main) => 2,
608        Some(DecisionKind::Climax) => 3,
609        Some(DecisionKind::AttackDeclaration) => 4,
610        Some(DecisionKind::LevelUp) => 5,
611        Some(DecisionKind::Encore) => 6,
612        Some(DecisionKind::TriggerOrder) => 7,
613        Some(DecisionKind::Choice) => 8,
614        None => -1,
615    }
616}
617
618fn attack_type_to_i32(attack_type: AttackType) -> i32 {
619    match attack_type {
620        AttackType::Frontal => 0,
621        AttackType::Side => 1,
622        AttackType::Direct => 2,
623    }
624}
625
626fn status_to_i32(status: StageStatus) -> i32 {
627    match status {
628        StageStatus::Stand => 1,
629        StageStatus::Rest => 2,
630        StageStatus::Reverse => 3,
631    }
632}
633
634fn terminal_to_i32(term: Option<TerminalResult>) -> i32 {
635    match term {
636        None => 0,
637        Some(TerminalResult::Win { winner }) => {
638            if winner == 0 {
639                1
640            } else {
641                2
642            }
643        }
644        Some(TerminalResult::Draw) => 3,
645        Some(TerminalResult::Timeout) => 4,
646    }
647}
648
649fn last_action_to_fields(
650    action: Option<&ActionDesc>,
651    actor: Option<u8>,
652    perspective: u8,
653    visibility: ObservationVisibility,
654) -> (i32, i32, i32) {
655    let mask = visibility == ObservationVisibility::Public
656        && actor.map(|p| p != perspective).unwrap_or(false);
657    match action {
658        None => (0, -1, -1),
659        Some(ActionDesc::MulliganConfirm) => (1, -1, -1),
660        Some(ActionDesc::MulliganSelect { hand_index }) => {
661            let idx = if mask { -1 } else { *hand_index as i32 };
662            (2, idx, -1)
663        }
664        Some(ActionDesc::Pass) => (3, -1, -1),
665        Some(ActionDesc::Clock { hand_index }) => {
666            let idx = if mask { -1 } else { *hand_index as i32 };
667            (4, idx, -1)
668        }
669        Some(ActionDesc::MainPlayCharacter {
670            hand_index,
671            stage_slot,
672        }) => {
673            let idx = if mask { -1 } else { *hand_index as i32 };
674            (6, idx, *stage_slot as i32)
675        }
676        Some(ActionDesc::MainPlayEvent { hand_index }) => {
677            let idx = if mask { -1 } else { *hand_index as i32 };
678            (7, idx, -1)
679        }
680        Some(ActionDesc::MainMove { from_slot, to_slot }) => {
681            (8, *from_slot as i32, *to_slot as i32)
682        }
683        Some(ActionDesc::MainActivateAbility {
684            slot,
685            ability_index,
686        }) => (9, *slot as i32, *ability_index as i32),
687        Some(ActionDesc::ClimaxPlay { hand_index }) => {
688            let idx = if mask { -1 } else { *hand_index as i32 };
689            (11, idx, -1)
690        }
691        Some(ActionDesc::Attack { slot, attack_type }) => {
692            (13, *slot as i32, attack_type_to_i32(*attack_type))
693        }
694        Some(ActionDesc::CounterPlay { hand_index }) => {
695            let idx = if mask { -1 } else { *hand_index as i32 };
696            (15, idx, -1)
697        }
698        Some(ActionDesc::LevelUp { index }) => (16, *index as i32, -1),
699        Some(ActionDesc::EncorePay { slot }) => (17, *slot as i32, -1),
700        Some(ActionDesc::EncoreDecline { slot }) => (22, *slot as i32, -1),
701        Some(ActionDesc::TriggerOrder { index }) => (18, *index as i32, -1),
702        Some(ActionDesc::ChoiceSelect { index }) => {
703            let idx = if mask { -1 } else { *index as i32 };
704            (19, idx, -1)
705        }
706        Some(ActionDesc::ChoicePrevPage) => (20, -1, -1),
707        Some(ActionDesc::ChoiceNextPage) => (21, -1, -1),
708        Some(ActionDesc::Concede) => (23, -1, -1),
709    }
710}