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#[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)]
41pub(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)]
92pub(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)]
135pub(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)]
338pub(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
353pub(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
361pub(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)]
370pub(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}