1use std::collections::BTreeMap;
2
3use anyhow::Result;
4use serde::Serialize;
5
6use crate::config::ObservationVisibility;
7use crate::db::{CardColor, CardId, CardType, TriggerIcon};
8use crate::encode::{action_desc_for_id, decode_action_id, ActionParamValue, MAX_STAGE};
9use crate::events::Zone;
10use crate::fingerprint::{hash_bytes, hash_postcard};
11use crate::legal::{ActionDesc, DecisionKind};
12use crate::state::{AttackType, ChoiceOptionRef, ChoiceReason, ChoiceZone, StageStatus};
13use crate::visibility_policy::{
14 target_zone_identity_visibility, zone_identity_visibility, ZoneIdentityVisibility,
15};
16
17use super::{GameEnv, VisibilityContext};
18
19const HUMAN_VIEW_SCHEMA_VERSION: &str = "human_decision_view_v1";
20const PUBLIC_EVENT_LOG_LIMIT: usize = 16;
21
22#[derive(Clone, Debug, Serialize)]
23struct HumanDecisionViewCore {
24 schema_version: &'static str,
25 simulator_version: &'static str,
26 env_index: u32,
27 episode_key: String,
28 episode_index: u32,
29 decision_id: u32,
30 summary: HumanSummaryView,
31 stage_layout: HumanStageLayoutView,
32 players: Vec<HumanPlayerView>,
33 public_event_log: Vec<serde_json::Value>,
34 legal_actions: Vec<HumanLegalActionView>,
35 legal_action_ids: Vec<u16>,
36 legal_fingerprint64: String,
37}
38
39#[derive(Clone, Debug, Serialize)]
40struct HumanSummaryView {
41 turn_player: u8,
42 actor_seat: Option<u8>,
43 viewer_seat: u8,
44 phase: &'static str,
45 decision_kind: Option<&'static str>,
46 decision_id: u32,
47 turn_count: u32,
48 turn_number: u32,
49 decision_count: u32,
50 tick_count: u32,
51 terminal: Option<String>,
52 players: Vec<HumanPlayerCountsView>,
53}
54
55#[derive(Clone, Debug, Serialize)]
56struct HumanPlayerCountsView {
57 seat: u8,
58 relative: &'static str,
59 level_count: usize,
60 clock_count: usize,
61 hand_count: usize,
62 stock_count: usize,
63 deck_count: usize,
64 waiting_room_count: usize,
65 memory_count: usize,
66 climax_count: usize,
67 resolution_count: usize,
68}
69
70#[derive(Clone, Debug, Serialize)]
71struct HumanStageLayoutView {
72 center_slots: Vec<u8>,
73 back_slots: Vec<u8>,
74 slots: Vec<HumanStageSlotMetaView>,
75}
76
77#[derive(Clone, Debug, Serialize)]
78struct HumanStageSlotMetaView {
79 slot: u8,
80 row: &'static str,
81 label: &'static str,
82}
83
84#[derive(Clone, Debug, Serialize)]
85struct HumanPlayerView {
86 seat: u8,
87 relative: &'static str,
88 counts: HumanPlayerCountsView,
89 zones: HumanZonesView,
90 stage: Vec<HumanStageSlotView>,
91}
92
93#[derive(Clone, Debug, Serialize)]
94struct HumanZonesView {
95 deck: HumanZoneView,
96 hand: HumanZoneView,
97 waiting_room: HumanZoneView,
98 clock: HumanZoneView,
99 level: HumanZoneView,
100 stock: HumanZoneView,
101 memory: HumanZoneView,
102 climax: HumanZoneView,
103 resolution: HumanZoneView,
104}
105
106#[derive(Clone, Debug, Serialize)]
107struct HumanZoneView {
108 zone: &'static str,
109 owner_seat: u8,
110 relative_owner: &'static str,
111 count: usize,
112 visibility: &'static str,
113 #[serde(skip_serializing_if = "Option::is_none")]
114 cards: Option<Vec<HumanZoneCardView>>,
115}
116
117#[derive(Clone, Debug, Serialize)]
118struct HumanZoneCardView {
119 card_ref: String,
120 zone: &'static str,
121 owner_seat: u8,
122 relative_owner: &'static str,
123 index: usize,
124 visibility: &'static str,
125 card: HumanCardRecord,
126}
127
128#[derive(Clone, Debug, Serialize)]
129struct HumanStageSlotView {
130 slot: u8,
131 row: &'static str,
132 label: &'static str,
133 slot_ref: String,
134 owner_seat: u8,
135 relative_owner: &'static str,
136 visibility: &'static str,
137 empty: bool,
138 #[serde(skip_serializing_if = "Option::is_none")]
139 card_ref: Option<String>,
140 #[serde(skip_serializing_if = "Option::is_none")]
141 card: Option<HumanCardRecord>,
142 #[serde(skip_serializing_if = "Option::is_none")]
143 orientation: Option<&'static str>,
144 marker_count: usize,
145 has_attacked: bool,
146 cannot_attack: bool,
147 attack_cost: u8,
148 #[serde(skip_serializing_if = "Option::is_none")]
149 power: Option<i32>,
150 #[serde(skip_serializing_if = "Option::is_none")]
151 soul: Option<u8>,
152 #[serde(skip_serializing_if = "Option::is_none")]
153 effective_soul: Option<i32>,
154 #[serde(skip_serializing_if = "Option::is_none")]
155 level: Option<i32>,
156 #[serde(skip_serializing_if = "Option::is_none")]
157 cost: Option<u8>,
158 #[serde(skip_serializing_if = "Option::is_none")]
159 color: Option<&'static str>,
160}
161
162#[derive(Clone, Debug, Serialize)]
163struct HumanCardRecord {
164 card_id: CardId,
165 #[serde(skip_serializing_if = "Option::is_none")]
166 card_type: Option<&'static str>,
167 #[serde(skip_serializing_if = "Option::is_none")]
168 color: Option<&'static str>,
169 #[serde(skip_serializing_if = "Option::is_none")]
170 level: Option<u8>,
171 #[serde(skip_serializing_if = "Option::is_none")]
172 cost: Option<u8>,
173 #[serde(skip_serializing_if = "Option::is_none")]
174 power: Option<i32>,
175 #[serde(skip_serializing_if = "Option::is_none")]
176 soul: Option<u8>,
177 #[serde(skip_serializing_if = "Vec::is_empty")]
178 triggers: Vec<&'static str>,
179 #[serde(skip_serializing_if = "Vec::is_empty")]
180 traits: Vec<u16>,
181}
182
183#[derive(Clone, Debug, Serialize)]
184struct HumanLegalActionView {
185 index: usize,
186 action_id: u16,
187 family: String,
188 label: String,
189 short_label: String,
190 description: String,
191 params: BTreeMap<String, HumanActionParamValue>,
192 source_refs: Vec<HumanActionRefView>,
193 target_refs: Vec<HumanActionRefView>,
194 is_pass: bool,
195 is_attack: bool,
196 is_play: bool,
197 is_move: bool,
198}
199
200#[derive(Clone, Debug, Serialize)]
201#[serde(untagged)]
202enum HumanActionParamValue {
203 Int(i32),
204 Str(String),
205}
206
207#[derive(Clone, Debug, Serialize)]
208struct HumanActionRefView {
209 ref_id: String,
210 zone: &'static str,
211 owner_seat: u8,
212 relative_owner: &'static str,
213 visibility: &'static str,
214 #[serde(skip_serializing_if = "Option::is_none")]
215 index: Option<u16>,
216 #[serde(skip_serializing_if = "Option::is_none")]
217 slot: Option<u8>,
218 #[serde(skip_serializing_if = "Option::is_none")]
219 card: Option<HumanCardRecord>,
220}
221
222impl GameEnv {
223 pub fn human_decision_view_json(&self, perspective_seat: Option<u8>) -> Result<String> {
228 let viewer = self.resolve_human_viewer(perspective_seat)?;
229 let ctx = VisibilityContext {
230 viewer: Some(viewer),
231 mode: ObservationVisibility::Public,
232 policies_enabled: true,
233 };
234 let event_ctx = VisibilityContext {
235 viewer: None,
236 mode: ObservationVisibility::Public,
237 policies_enabled: true,
238 };
239 let actor = self.decision.as_ref().map(|decision| decision.player);
240 let viewer_is_actor = actor == Some(viewer);
241 let legal_action_ids = if viewer_is_actor {
242 self.action_ids_cache().to_vec()
243 } else {
244 Vec::new()
245 };
246 let legal_fingerprint64 = self.legal_fingerprint64(&legal_action_ids);
247 let core = HumanDecisionViewCore {
248 schema_version: HUMAN_VIEW_SCHEMA_VERSION,
249 simulator_version: env!("CARGO_PKG_VERSION"),
250 env_index: self.env_id,
251 episode_key: self.human_episode_key(),
252 episode_index: self.episode_index,
253 decision_id: self.decision_id(),
254 summary: self.build_human_summary(viewer),
255 stage_layout: self.build_human_stage_layout(),
256 players: self.build_human_players(viewer, ctx),
257 public_event_log: self.build_public_event_log(event_ctx),
258 legal_actions: self.build_human_legal_actions(viewer, ctx, &legal_action_ids),
259 legal_action_ids,
260 legal_fingerprint64,
261 };
262 let view_hash64 = format_hash64(hash_postcard(&core));
263 let mut value = serde_json::to_value(core)?;
264 if let serde_json::Value::Object(map) = &mut value {
265 map.insert(
266 "view_hash64".to_string(),
267 serde_json::Value::String(view_hash64),
268 );
269 }
270 Ok(serde_json::to_string(&value)?)
271 }
272
273 fn resolve_human_viewer(&self, perspective_seat: Option<u8>) -> Result<u8> {
274 let viewer = perspective_seat
275 .or_else(|| self.decision.as_ref().map(|decision| decision.player))
276 .unwrap_or(self.state.turn.active_player);
277 if viewer > 1 {
278 anyhow::bail!("perspective_seat must be 0, 1, or None (got {viewer})");
279 }
280 Ok(viewer)
281 }
282
283 fn human_episode_key(&self) -> String {
284 let mut bytes = Vec::with_capacity(24);
285 bytes.extend_from_slice(b"human-episode-v1");
286 bytes.extend_from_slice(&self.env_id.to_le_bytes());
287 bytes.extend_from_slice(&self.episode_index.to_le_bytes());
288 format!("episode:{}", format_hash64(hash_bytes(&bytes)))
289 }
290
291 fn build_human_summary(&self, viewer: u8) -> HumanSummaryView {
292 let decision = self.decision.as_ref();
293 HumanSummaryView {
294 turn_player: self.state.turn.active_player,
295 actor_seat: decision.map(|d| d.player),
296 viewer_seat: viewer,
297 phase: phase_name(self.state.turn.phase),
298 decision_kind: decision.map(|d| decision_kind_name(d.kind)),
299 decision_id: self.decision_id(),
300 turn_count: self.state.turn.turn_number,
301 turn_number: self.state.turn.turn_number,
302 decision_count: self.state.turn.decision_count,
303 tick_count: self.state.turn.tick_count,
304 terminal: self.state.terminal.map(terminal_name),
305 players: (0..2)
306 .map(|seat| self.player_counts_view(seat, viewer))
307 .collect(),
308 }
309 }
310
311 fn build_human_stage_layout(&self) -> HumanStageLayoutView {
312 let center_slots = if self.curriculum.reduced_stage_mode {
313 vec![0]
314 } else {
315 vec![0, 1, 2]
316 };
317 let back_slots = if self.curriculum.reduced_stage_mode {
318 Vec::new()
319 } else {
320 vec![3, 4]
321 };
322 let slots = (0..MAX_STAGE)
323 .map(|slot| HumanStageSlotMetaView {
324 slot: slot as u8,
325 row: stage_row(slot as u8),
326 label: stage_slot_label(slot as u8),
327 })
328 .collect();
329 HumanStageLayoutView {
330 center_slots,
331 back_slots,
332 slots,
333 }
334 }
335
336 fn build_human_players(&self, viewer: u8, ctx: VisibilityContext) -> Vec<HumanPlayerView> {
337 (0..2)
338 .map(|seat| {
339 let seat_u8 = seat as u8;
340 HumanPlayerView {
341 seat: seat_u8,
342 relative: relative_owner(viewer, seat_u8),
343 counts: self.player_counts_view(seat_u8, viewer),
344 zones: self.build_human_zones(seat_u8, viewer, ctx),
345 stage: self.build_human_stage(seat_u8, viewer, ctx),
346 }
347 })
348 .collect()
349 }
350
351 fn player_counts_view(&self, seat: u8, viewer: u8) -> HumanPlayerCountsView {
352 let player = &self.state.players[seat as usize];
353 HumanPlayerCountsView {
354 seat,
355 relative: relative_owner(viewer, seat),
356 level_count: player.level.len(),
357 clock_count: player.clock.len(),
358 hand_count: player.hand.len(),
359 stock_count: player.stock.len(),
360 deck_count: player.deck.len(),
361 waiting_room_count: player.waiting_room.len(),
362 memory_count: player.memory.len(),
363 climax_count: player.climax.len(),
364 resolution_count: player.resolution.len(),
365 }
366 }
367
368 fn build_human_zones(&self, owner: u8, viewer: u8, ctx: VisibilityContext) -> HumanZonesView {
369 let player = &self.state.players[owner as usize];
370 HumanZonesView {
371 deck: self.zone_view(owner, viewer, ctx, Zone::Deck, &player.deck),
372 hand: self.zone_view(owner, viewer, ctx, Zone::Hand, &player.hand),
373 waiting_room: self.zone_view(
374 owner,
375 viewer,
376 ctx,
377 Zone::WaitingRoom,
378 &player.waiting_room,
379 ),
380 clock: self.zone_view(owner, viewer, ctx, Zone::Clock, &player.clock),
381 level: self.zone_view(owner, viewer, ctx, Zone::Level, &player.level),
382 stock: self.zone_view(owner, viewer, ctx, Zone::Stock, &player.stock),
383 memory: self.zone_view(owner, viewer, ctx, Zone::Memory, &player.memory),
384 climax: self.zone_view(owner, viewer, ctx, Zone::Climax, &player.climax),
385 resolution: self.zone_view(owner, viewer, ctx, Zone::Resolution, &player.resolution),
386 }
387 }
388
389 fn zone_view(
390 &self,
391 owner: u8,
392 viewer: u8,
393 ctx: VisibilityContext,
394 zone: Zone,
395 cards: &[crate::state::CardInstance],
396 ) -> HumanZoneView {
397 let hidden = self.human_zone_hidden_for_viewer(ctx, owner, zone);
398 let visibility = zone_visibility_label(owner, viewer, zone, &self.curriculum, hidden);
399 let zone_name = zone_name(zone);
400 let card_views = (!hidden).then(|| {
401 cards
402 .iter()
403 .enumerate()
404 .map(|(index, card)| HumanZoneCardView {
405 card_ref: card_ref(viewer, owner, zone_name, index as u16),
406 zone: zone_name,
407 owner_seat: owner,
408 relative_owner: relative_owner(viewer, owner),
409 index,
410 visibility,
411 card: self.card_record(card.id),
412 })
413 .collect()
414 });
415 HumanZoneView {
416 zone: zone_name,
417 owner_seat: owner,
418 relative_owner: relative_owner(viewer, owner),
419 count: cards.len(),
420 visibility,
421 cards: card_views,
422 }
423 }
424
425 fn human_zone_hidden_for_viewer(&self, ctx: VisibilityContext, owner: u8, zone: Zone) -> bool {
426 matches!(zone, Zone::Deck | Zone::Stock) || self.zone_hidden_for_viewer(ctx, owner, zone)
427 }
428
429 fn build_human_stage(
430 &self,
431 owner: u8,
432 viewer: u8,
433 _ctx: VisibilityContext,
434 ) -> Vec<HumanStageSlotView> {
435 let visibility = zone_visibility_label(owner, viewer, Zone::Stage, &self.curriculum, false);
436 self.state.players[owner as usize]
437 .stage
438 .iter()
439 .enumerate()
440 .map(|(slot, slot_state)| {
441 let slot_u8 = slot as u8;
442 let card = slot_state.card.map(|card| self.card_record(card.id));
443 let power = slot_state
444 .card
445 .map(|card| self.effective_slot_power(owner as usize, slot, card.id));
446 let level = slot_state
447 .card
448 .map(|_| self.compute_slot_level(owner as usize, slot).max(0));
449 let effective_soul = slot_state
450 .card
451 .map(|card| self.effective_slot_soul(owner as usize, slot, card.id));
452 HumanStageSlotView {
453 slot: slot_u8,
454 row: stage_row(slot_u8),
455 label: stage_slot_label(slot_u8),
456 slot_ref: card_ref(viewer, owner, "stage", slot_u8 as u16),
457 owner_seat: owner,
458 relative_owner: relative_owner(viewer, owner),
459 visibility,
460 empty: slot_state.card.is_none(),
461 card_ref: slot_state
462 .card
463 .map(|_| card_ref(viewer, owner, "stage", slot_u8 as u16)),
464 card,
465 orientation: slot_state
466 .card
467 .map(|_| stage_status_name(slot_state.status)),
468 marker_count: slot_state.markers.len(),
469 has_attacked: slot_state.has_attacked,
470 cannot_attack: slot_state.cannot_attack,
471 attack_cost: slot_state.attack_cost,
472 power,
473 soul: slot_state.card.map(|card| self.db.soul_by_id(card.id)),
474 effective_soul,
475 level,
476 cost: slot_state.card.map(|card| self.db.cost_by_id(card.id)),
477 color: slot_state
478 .card
479 .map(|card| color_name(self.db.color_by_id(card.id))),
480 }
481 })
482 .collect()
483 }
484
485 fn build_public_event_log(&self, ctx: VisibilityContext) -> Vec<serde_json::Value> {
486 let events = self.canonical_events();
487 let start = events.len().saturating_sub(PUBLIC_EVENT_LOG_LIMIT);
488 events[start..]
489 .iter()
490 .filter_map(|event| {
491 let sanitized = self.sanitize_event_for_viewer(event, ctx);
492 serde_json::to_value(sanitized)
493 .ok()
494 .map(strip_instance_ids_from_value)
495 })
496 .collect()
497 }
498
499 fn build_human_legal_actions(
500 &self,
501 viewer: u8,
502 ctx: VisibilityContext,
503 legal_action_ids: &[u16],
504 ) -> Vec<HumanLegalActionView> {
505 let actor = self.decision.as_ref().map(|d| d.player);
506 legal_action_ids
507 .iter()
508 .enumerate()
509 .map(|(index, &action_id)| {
510 let desc = decode_action_id(action_id as usize);
511 let family = desc
512 .as_ref()
513 .map(|d| d.family.to_string())
514 .unwrap_or_else(|| "unknown".to_string());
515 let params = desc.as_ref().map(action_params_map).unwrap_or_default();
516 let action = action_desc_for_id(action_id as usize);
517 let (source_refs, target_refs) = action
518 .as_ref()
519 .and_then(|action| actor.map(|actor| (actor, action)))
520 .map(|(actor, action)| self.action_refs(viewer, ctx, actor, action))
521 .unwrap_or_default();
522 let (label, short_label, description) = action
523 .as_ref()
524 .map(|action| self.action_labels(action, actor))
525 .unwrap_or_else(|| {
526 (
527 format!("Action {action_id}"),
528 format!("#{action_id}"),
529 "Unknown action id in legal cache".to_string(),
530 )
531 });
532 HumanLegalActionView {
533 index,
534 action_id,
535 family,
536 label,
537 short_label,
538 description,
539 params,
540 source_refs,
541 target_refs,
542 is_pass: matches!(action, Some(ActionDesc::Pass)),
543 is_attack: matches!(action, Some(ActionDesc::Attack { .. })),
544 is_play: matches!(
545 action,
546 Some(
547 ActionDesc::MainPlayCharacter { .. }
548 | ActionDesc::MainPlayEvent { .. }
549 | ActionDesc::ClimaxPlay { .. }
550 | ActionDesc::CounterPlay { .. }
551 )
552 ),
553 is_move: matches!(action, Some(ActionDesc::MainMove { .. })),
554 }
555 })
556 .collect()
557 }
558
559 fn action_refs(
560 &self,
561 viewer: u8,
562 ctx: VisibilityContext,
563 actor: u8,
564 action: &ActionDesc,
565 ) -> (Vec<HumanActionRefView>, Vec<HumanActionRefView>) {
566 match action {
567 ActionDesc::MulliganSelect { hand_index }
568 | ActionDesc::Clock { hand_index }
569 | ActionDesc::MainPlayEvent { hand_index }
570 | ActionDesc::ClimaxPlay { hand_index }
571 | ActionDesc::CounterPlay { hand_index } => (
572 vec![self.action_zone_ref(viewer, ctx, actor, Zone::Hand, *hand_index)],
573 vec![],
574 ),
575 ActionDesc::MainPlayCharacter {
576 hand_index,
577 stage_slot,
578 } => (
579 vec![self.action_zone_ref(viewer, ctx, actor, Zone::Hand, *hand_index)],
580 vec![self.action_stage_ref(viewer, actor, *stage_slot)],
581 ),
582 ActionDesc::MainMove { from_slot, to_slot } => (
583 vec![self.action_stage_ref(viewer, actor, *from_slot)],
584 vec![self.action_stage_ref(viewer, actor, *to_slot)],
585 ),
586 ActionDesc::MainActivateAbility { slot, .. }
587 | ActionDesc::Attack { slot, .. }
588 | ActionDesc::EncorePay { slot }
589 | ActionDesc::EncoreDecline { slot } => (
590 vec![self.action_stage_ref(viewer, actor, *slot)],
591 self.attack_target_refs(viewer, actor, action),
592 ),
593 ActionDesc::LevelUp { index } => (
594 vec![self.action_zone_ref(viewer, ctx, actor, Zone::Clock, *index)],
595 vec![self.action_zone_target_ref(viewer, actor, Zone::Level, None)],
596 ),
597 ActionDesc::ChoiceSelect { index } => {
598 let refs = self.choice_action_ref(viewer, ctx, actor, *index);
599 (refs, vec![])
600 }
601 ActionDesc::TriggerOrder { index } => (
602 vec![self.action_zone_target_ref(viewer, actor, Zone::Resolution, Some(*index))],
603 vec![],
604 ),
605 ActionDesc::MulliganConfirm
606 | ActionDesc::Pass
607 | ActionDesc::ChoicePrevPage
608 | ActionDesc::ChoiceNextPage
609 | ActionDesc::Concede => (vec![], vec![]),
610 }
611 }
612
613 fn action_zone_ref(
614 &self,
615 viewer: u8,
616 ctx: VisibilityContext,
617 owner: u8,
618 zone: Zone,
619 index: u8,
620 ) -> HumanActionRefView {
621 let hidden = self.human_zone_hidden_for_viewer(ctx, owner, zone);
622 let zone_name = zone_name(zone);
623 let card = if hidden {
624 None
625 } else {
626 self.zone_card_id(owner, zone, index as usize)
627 .map(|card_id| self.card_record(card_id))
628 };
629 HumanActionRefView {
630 ref_id: if hidden {
631 hidden_ref(viewer, owner, zone_name)
632 } else {
633 card_ref(viewer, owner, zone_name, index as u16)
634 },
635 zone: zone_name,
636 owner_seat: owner,
637 relative_owner: relative_owner(viewer, owner),
638 visibility: zone_visibility_label(owner, viewer, zone, &self.curriculum, hidden),
639 index: (!hidden).then_some(index as u16),
640 slot: None,
641 card,
642 }
643 }
644
645 fn action_zone_target_ref(
646 &self,
647 viewer: u8,
648 owner: u8,
649 zone: Zone,
650 index: Option<u8>,
651 ) -> HumanActionRefView {
652 let zone_name = zone_name(zone);
653 HumanActionRefView {
654 ref_id: index
655 .map(|idx| card_ref(viewer, owner, zone_name, idx as u16))
656 .unwrap_or_else(|| format!("{}.{}", relative_owner(viewer, owner), zone_name)),
657 zone: zone_name,
658 owner_seat: owner,
659 relative_owner: relative_owner(viewer, owner),
660 visibility: zone_visibility_label(owner, viewer, zone, &self.curriculum, false),
661 index: index.map(u16::from),
662 slot: None,
663 card: None,
664 }
665 }
666
667 fn action_stage_ref(&self, viewer: u8, owner: u8, slot: u8) -> HumanActionRefView {
668 let card = self.state.players[owner as usize].stage[slot as usize]
669 .card
670 .map(|card| self.card_record(card.id));
671 HumanActionRefView {
672 ref_id: card_ref(viewer, owner, "stage", slot as u16),
673 zone: "stage",
674 owner_seat: owner,
675 relative_owner: relative_owner(viewer, owner),
676 visibility: "public",
677 index: Some(slot as u16),
678 slot: Some(slot),
679 card,
680 }
681 }
682
683 fn attack_target_refs(
684 &self,
685 viewer: u8,
686 actor: u8,
687 action: &ActionDesc,
688 ) -> Vec<HumanActionRefView> {
689 let ActionDesc::Attack { slot, attack_type } = action else {
690 return Vec::new();
691 };
692 if *attack_type == AttackType::Direct {
693 return Vec::new();
694 }
695 let opponent = 1 - actor;
696 let slot_idx = *slot as usize;
697 if slot_idx >= MAX_STAGE
698 || self.state.players[opponent as usize].stage[slot_idx]
699 .card
700 .is_none()
701 {
702 return Vec::new();
703 }
704 vec![self.action_stage_ref(viewer, opponent, *slot)]
705 }
706
707 fn choice_action_ref(
708 &self,
709 viewer: u8,
710 ctx: VisibilityContext,
711 actor: u8,
712 page_index: u8,
713 ) -> Vec<HumanActionRefView> {
714 let Some(choice) = self.state.turn.choice.as_ref() else {
715 return vec![self.action_zone_target_ref(
716 viewer,
717 actor,
718 Zone::Resolution,
719 Some(page_index),
720 )];
721 };
722 let global_idx = choice.page_start as usize + page_index as usize;
723 let Some(option) = choice.options.get(global_idx) else {
724 return Vec::new();
725 };
726 let sanitized =
727 self.sanitize_choice_option_for_event(choice.reason, choice.player, ctx, option);
728 vec![self.choice_option_ref(viewer, actor, choice.reason, &sanitized)]
729 }
730
731 fn choice_option_ref(
732 &self,
733 viewer: u8,
734 actor: u8,
735 reason: ChoiceReason,
736 option: &ChoiceOptionRef,
737 ) -> HumanActionRefView {
738 let owner = self.choice_option_owner(reason, actor);
739 let zone = choice_zone_name(option.zone);
740 let hidden = matches!(option.zone, ChoiceZone::DeckTop | ChoiceZone::Stock)
741 || (option.card_id == 0 && option.index.is_none() && choice_zone_private(option.zone));
742 HumanActionRefView {
743 ref_id: match option.index {
744 Some(index) if !hidden => card_ref(viewer, owner, zone, index),
745 None if option.zone == ChoiceZone::Stage => option
746 .target_slot
747 .map(|slot| card_ref(viewer, owner, "stage", slot as u16))
748 .unwrap_or_else(|| hidden_ref(viewer, owner, zone)),
749 _ => hidden_ref(viewer, owner, zone),
750 },
751 zone,
752 owner_seat: owner,
753 relative_owner: relative_owner(viewer, owner),
754 visibility: choice_zone_visibility_label(
755 owner,
756 viewer,
757 option.zone,
758 &self.curriculum,
759 hidden,
760 ),
761 index: (!hidden).then_some(option.index).flatten(),
762 slot: (!hidden).then_some(option.target_slot).flatten(),
763 card: (!hidden && option.card_id != 0).then(|| self.card_record(option.card_id)),
764 }
765 }
766
767 fn choice_option_owner(&self, reason: ChoiceReason, player: u8) -> u8 {
768 if reason != ChoiceReason::TargetSelect {
769 return player;
770 }
771 let Some(selection) = self.state.turn.target_selection.as_ref() else {
772 return player;
773 };
774 match selection.spec.side {
775 crate::state::TargetSide::SelfSide => selection.controller,
776 crate::state::TargetSide::Opponent => 1 - selection.controller,
777 }
778 }
779
780 fn action_labels(&self, action: &ActionDesc, actor: Option<u8>) -> (String, String, String) {
781 match action {
782 ActionDesc::MulliganConfirm => (
783 "Confirm mulligan".to_string(),
784 "Keep".to_string(),
785 "Finish selecting cards for mulligan.".to_string(),
786 ),
787 ActionDesc::MulliganSelect { hand_index } => (
788 format!("Toggle hand card {}", hand_index + 1),
789 format!("Toggle {}", hand_index + 1),
790 "Select or unselect this card for mulligan.".to_string(),
791 ),
792 ActionDesc::Pass => (
793 "Pass".to_string(),
794 "Pass".to_string(),
795 "Take no optional action for this decision.".to_string(),
796 ),
797 ActionDesc::Clock { hand_index } => (
798 format!("Clock hand card {}", hand_index + 1),
799 format!("Clock {}", hand_index + 1),
800 "Place this hand card into clock.".to_string(),
801 ),
802 ActionDesc::MainPlayCharacter {
803 hand_index,
804 stage_slot,
805 } => (
806 format!(
807 "Play hand card {} to {}",
808 hand_index + 1,
809 stage_slot_label(*stage_slot)
810 ),
811 format!("Play {}", hand_index + 1),
812 "Play this character from hand to the selected stage slot.".to_string(),
813 ),
814 ActionDesc::MainPlayEvent { hand_index } => (
815 format!("Play event from hand card {}", hand_index + 1),
816 format!("Event {}", hand_index + 1),
817 "Play this event card from hand.".to_string(),
818 ),
819 ActionDesc::MainMove { from_slot, to_slot } => (
820 format!(
821 "Move {} to {}",
822 stage_slot_label(*from_slot),
823 stage_slot_label(*to_slot)
824 ),
825 "Move".to_string(),
826 "Move a character between stage slots.".to_string(),
827 ),
828 ActionDesc::MainActivateAbility {
829 slot,
830 ability_index,
831 } => (
832 format!(
833 "Use ability {} from {}",
834 ability_index + 1,
835 stage_slot_label(*slot)
836 ),
837 format!("ACT {}", ability_index + 1),
838 "Activate a stage character ability.".to_string(),
839 ),
840 ActionDesc::ClimaxPlay { hand_index } => (
841 format!("Play climax from hand card {}", hand_index + 1),
842 format!("Climax {}", hand_index + 1),
843 "Play this climax from hand.".to_string(),
844 ),
845 ActionDesc::Attack { slot, attack_type } => (
846 format!(
847 "{} attack with {}",
848 attack_type_label(*attack_type),
849 stage_slot_label(*slot)
850 ),
851 attack_type_short_label(*attack_type).to_string(),
852 "Declare an attack with this center-stage character.".to_string(),
853 ),
854 ActionDesc::CounterPlay { hand_index } => (
855 format!("Play counter from hand card {}", hand_index + 1),
856 format!("Counter {}", hand_index + 1),
857 "Play this counter card from hand.".to_string(),
858 ),
859 ActionDesc::LevelUp { index } => (
860 format!("Level up with clock card {}", index + 1),
861 format!("Level {}", index + 1),
862 "Move this clock card to level.".to_string(),
863 ),
864 ActionDesc::EncorePay { slot } => (
865 format!("Pay encore for {}", stage_slot_label(*slot)),
866 "Encore".to_string(),
867 "Pay stock to keep this character on stage.".to_string(),
868 ),
869 ActionDesc::EncoreDecline { slot } => (
870 format!("Decline encore for {}", stage_slot_label(*slot)),
871 "Decline".to_string(),
872 "Do not pay encore for this character.".to_string(),
873 ),
874 ActionDesc::TriggerOrder { index } => (
875 format!("Resolve trigger {}", index + 1),
876 format!("Trigger {}", index + 1),
877 "Choose this trigger to resolve next.".to_string(),
878 ),
879 ActionDesc::ChoiceSelect { index } => (
880 format!("Choose option {}", index + 1),
881 format!("Choice {}", index + 1),
882 actor
883 .map(|_| "Select this option from the current choice page.".to_string())
884 .unwrap_or_else(|| "Select this choice option.".to_string()),
885 ),
886 ActionDesc::ChoicePrevPage => (
887 "Previous choice page".to_string(),
888 "Previous".to_string(),
889 "Show the previous page of choice options.".to_string(),
890 ),
891 ActionDesc::ChoiceNextPage => (
892 "Next choice page".to_string(),
893 "Next".to_string(),
894 "Show the next page of choice options.".to_string(),
895 ),
896 ActionDesc::Concede => (
897 "Concede".to_string(),
898 "Concede".to_string(),
899 "Concede the game.".to_string(),
900 ),
901 }
902 }
903
904 fn card_record(&self, card_id: CardId) -> HumanCardRecord {
905 if let Some(card) = self.db.get(card_id) {
906 HumanCardRecord {
907 card_id,
908 card_type: Some(card_type_name(card.card_type)),
909 color: Some(color_name(card.color)),
910 level: Some(card.level),
911 cost: Some(card.cost),
912 power: Some(card.power),
913 soul: Some(card.soul),
914 triggers: card.triggers.iter().copied().map(trigger_name).collect(),
915 traits: card.traits.clone(),
916 }
917 } else {
918 HumanCardRecord {
919 card_id,
920 card_type: None,
921 color: None,
922 level: None,
923 cost: None,
924 power: None,
925 soul: None,
926 triggers: Vec::new(),
927 traits: Vec::new(),
928 }
929 }
930 }
931
932 fn zone_card_id(&self, owner: u8, zone: Zone, index: usize) -> Option<CardId> {
933 let player = &self.state.players[owner as usize];
934 match zone {
935 Zone::Deck => player.deck.get(index).map(|card| card.id),
936 Zone::Hand => player.hand.get(index).map(|card| card.id),
937 Zone::WaitingRoom => player.waiting_room.get(index).map(|card| card.id),
938 Zone::Clock => player.clock.get(index).map(|card| card.id),
939 Zone::Level => player.level.get(index).map(|card| card.id),
940 Zone::Stock => player.stock.get(index).map(|card| card.id),
941 Zone::Memory => player.memory.get(index).map(|card| card.id),
942 Zone::Climax => player.climax.get(index).map(|card| card.id),
943 Zone::Resolution => player.resolution.get(index).map(|card| card.id),
944 Zone::Stage => player
945 .stage
946 .get(index)
947 .and_then(|slot| slot.card.map(|card| card.id)),
948 }
949 }
950
951 fn effective_slot_power(&self, player: usize, slot: usize, card_id: CardId) -> i32 {
952 let slot_state = &self.state.players[player].stage[slot];
953 let mut power =
954 self.db.power_by_id(card_id) + slot_state.power_mod_turn + slot_state.power_mod_battle;
955 for modifier in &self.state.modifiers {
956 if modifier.kind != crate::state::ModifierKind::Power {
957 continue;
958 }
959 if modifier.target_player as usize == player
960 && modifier.target_slot as usize == slot
961 && modifier.target_card == card_id
962 {
963 power = power.saturating_add(modifier.magnitude);
964 }
965 }
966 power
967 }
968
969 fn effective_slot_soul(&self, player: usize, slot: usize, card_id: CardId) -> i32 {
970 let mut soul = i32::from(self.db.soul_by_id(card_id));
971 for modifier in &self.state.modifiers {
972 if modifier.kind != crate::state::ModifierKind::Soul {
973 continue;
974 }
975 if modifier.target_player as usize == player
976 && modifier.target_slot as usize == slot
977 && modifier.target_card == card_id
978 {
979 soul = soul.saturating_add(modifier.magnitude);
980 }
981 }
982 soul.max(0)
983 }
984
985 fn legal_fingerprint64(&self, legal_action_ids: &[u16]) -> String {
986 let mut bytes = Vec::with_capacity(24 + legal_action_ids.len() * 2);
987 bytes.extend_from_slice(b"human-legal-v1");
988 bytes.extend_from_slice(&self.decision_id().to_le_bytes());
989 if let Some(decision) = self.decision.as_ref() {
990 bytes.push(decision.player);
991 bytes.push(decision_kind_code(decision.kind));
992 } else {
993 bytes.push(u8::MAX);
994 bytes.push(u8::MAX);
995 }
996 for &action_id in legal_action_ids {
997 bytes.extend_from_slice(&action_id.to_le_bytes());
998 }
999 format_hash64(hash_bytes(&bytes))
1000 }
1001}
1002
1003fn action_params_map(
1004 desc: &crate::encode::ActionIdDesc,
1005) -> BTreeMap<String, HumanActionParamValue> {
1006 let mut params = BTreeMap::new();
1007 for param in &desc.params {
1008 let value = match ¶m.value {
1009 ActionParamValue::Int(value) => HumanActionParamValue::Int(*value),
1010 ActionParamValue::Str(value) => HumanActionParamValue::Str((*value).to_string()),
1011 };
1012 params.insert(param.name.to_string(), value);
1013 }
1014 params
1015}
1016
1017fn strip_instance_ids_from_value(value: serde_json::Value) -> serde_json::Value {
1018 match value {
1019 serde_json::Value::Object(mut map) => {
1020 map.remove("instance_id");
1021 serde_json::Value::Object(
1022 map.into_iter()
1023 .map(|(key, value)| (key, strip_instance_ids_from_value(value)))
1024 .collect(),
1025 )
1026 }
1027 serde_json::Value::Array(values) => serde_json::Value::Array(
1028 values
1029 .into_iter()
1030 .map(strip_instance_ids_from_value)
1031 .collect(),
1032 ),
1033 other => other,
1034 }
1035}
1036
1037fn format_hash64(value: u64) -> String {
1038 format!("{value:016x}")
1039}
1040
1041fn relative_owner(viewer: u8, owner: u8) -> &'static str {
1042 if viewer == owner {
1043 "self"
1044 } else {
1045 "opponent"
1046 }
1047}
1048
1049fn card_ref(viewer: u8, owner: u8, zone: &str, index: u16) -> String {
1050 format!("{}.{}.{}", relative_owner(viewer, owner), zone, index)
1051}
1052
1053fn hidden_ref(viewer: u8, owner: u8, zone: &str) -> String {
1054 format!("{}.{}.*", relative_owner(viewer, owner), zone)
1055}
1056
1057fn zone_visibility_label(
1058 owner: u8,
1059 viewer: u8,
1060 zone: Zone,
1061 curriculum: &crate::config::CurriculumConfig,
1062 hidden: bool,
1063) -> &'static str {
1064 if hidden {
1065 return if owner == viewer {
1066 "self_count_only"
1067 } else {
1068 "opponent_count_only"
1069 };
1070 }
1071 match zone_identity_visibility(zone, curriculum) {
1072 ZoneIdentityVisibility::Public => "public",
1073 ZoneIdentityVisibility::OwnerOnly if owner == viewer => "self_private",
1074 ZoneIdentityVisibility::OwnerOnly => "opponent_count_only",
1075 }
1076}
1077
1078fn choice_zone_visibility_label(
1079 owner: u8,
1080 viewer: u8,
1081 zone: ChoiceZone,
1082 curriculum: &crate::config::CurriculumConfig,
1083 hidden: bool,
1084) -> &'static str {
1085 if hidden {
1086 return if owner == viewer {
1087 "self_count_only"
1088 } else {
1089 "opponent_count_only"
1090 };
1091 }
1092 let target_zone = match choice_zone_to_target_zone(zone) {
1093 Some(zone) => zone,
1094 None => return "public",
1095 };
1096 match target_zone_identity_visibility(target_zone, curriculum) {
1097 ZoneIdentityVisibility::Public => "public",
1098 ZoneIdentityVisibility::OwnerOnly if owner == viewer => "self_private",
1099 ZoneIdentityVisibility::OwnerOnly => "opponent_count_only",
1100 }
1101}
1102
1103fn choice_zone_to_target_zone(zone: ChoiceZone) -> Option<crate::state::TargetZone> {
1104 match zone {
1105 ChoiceZone::WaitingRoom => Some(crate::state::TargetZone::WaitingRoom),
1106 ChoiceZone::Stage => Some(crate::state::TargetZone::Stage),
1107 ChoiceZone::Hand => Some(crate::state::TargetZone::Hand),
1108 ChoiceZone::DeckTop => Some(crate::state::TargetZone::DeckTop),
1109 ChoiceZone::Clock => Some(crate::state::TargetZone::Clock),
1110 ChoiceZone::Level => Some(crate::state::TargetZone::Level),
1111 ChoiceZone::Stock => Some(crate::state::TargetZone::Stock),
1112 ChoiceZone::Memory => Some(crate::state::TargetZone::Memory),
1113 ChoiceZone::Climax => Some(crate::state::TargetZone::Climax),
1114 ChoiceZone::Resolution => Some(crate::state::TargetZone::Resolution),
1115 ChoiceZone::Stack
1116 | ChoiceZone::PriorityCounter
1117 | ChoiceZone::PriorityAct
1118 | ChoiceZone::PriorityPass
1119 | ChoiceZone::Skip => None,
1120 }
1121}
1122
1123fn choice_zone_private(zone: ChoiceZone) -> bool {
1124 matches!(
1125 zone,
1126 ChoiceZone::Hand | ChoiceZone::DeckTop | ChoiceZone::Stock | ChoiceZone::PriorityCounter
1127 )
1128}
1129
1130fn phase_name(phase: crate::state::Phase) -> &'static str {
1131 match phase {
1132 crate::state::Phase::Mulligan => "mulligan",
1133 crate::state::Phase::Stand => "stand",
1134 crate::state::Phase::Draw => "draw",
1135 crate::state::Phase::Clock => "clock",
1136 crate::state::Phase::Main => "main",
1137 crate::state::Phase::Climax => "climax",
1138 crate::state::Phase::Attack => "attack",
1139 crate::state::Phase::End => "end",
1140 }
1141}
1142
1143fn decision_kind_name(kind: DecisionKind) -> &'static str {
1144 match kind {
1145 DecisionKind::Mulligan => "mulligan",
1146 DecisionKind::Clock => "clock",
1147 DecisionKind::Main => "main",
1148 DecisionKind::Climax => "climax",
1149 DecisionKind::AttackDeclaration => "attack_declaration",
1150 DecisionKind::LevelUp => "level_up",
1151 DecisionKind::Encore => "encore",
1152 DecisionKind::TriggerOrder => "trigger_order",
1153 DecisionKind::Choice => "choice",
1154 }
1155}
1156
1157fn decision_kind_code(kind: DecisionKind) -> u8 {
1158 match kind {
1159 DecisionKind::Mulligan => 0,
1160 DecisionKind::Clock => 1,
1161 DecisionKind::Main => 2,
1162 DecisionKind::Climax => 3,
1163 DecisionKind::AttackDeclaration => 4,
1164 DecisionKind::LevelUp => 5,
1165 DecisionKind::Encore => 6,
1166 DecisionKind::TriggerOrder => 7,
1167 DecisionKind::Choice => 8,
1168 }
1169}
1170
1171fn terminal_name(terminal: crate::state::TerminalResult) -> String {
1172 match terminal {
1173 crate::state::TerminalResult::Win { winner } => format!("win_p{winner}"),
1174 crate::state::TerminalResult::Draw => "draw".to_string(),
1175 crate::state::TerminalResult::Timeout => "timeout".to_string(),
1176 }
1177}
1178
1179fn zone_name(zone: Zone) -> &'static str {
1180 match zone {
1181 Zone::Deck => "deck",
1182 Zone::Hand => "hand",
1183 Zone::WaitingRoom => "waiting_room",
1184 Zone::Clock => "clock",
1185 Zone::Level => "level",
1186 Zone::Stock => "stock",
1187 Zone::Memory => "memory",
1188 Zone::Climax => "climax",
1189 Zone::Resolution => "resolution",
1190 Zone::Stage => "stage",
1191 }
1192}
1193
1194fn choice_zone_name(zone: ChoiceZone) -> &'static str {
1195 match zone {
1196 ChoiceZone::WaitingRoom => "waiting_room",
1197 ChoiceZone::Stage => "stage",
1198 ChoiceZone::Hand => "hand",
1199 ChoiceZone::DeckTop => "deck_top",
1200 ChoiceZone::Clock => "clock",
1201 ChoiceZone::Level => "level",
1202 ChoiceZone::Stock => "stock",
1203 ChoiceZone::Memory => "memory",
1204 ChoiceZone::Climax => "climax",
1205 ChoiceZone::Resolution => "resolution",
1206 ChoiceZone::Stack => "stack",
1207 ChoiceZone::PriorityCounter => "priority_counter",
1208 ChoiceZone::PriorityAct => "priority_act",
1209 ChoiceZone::PriorityPass => "priority_pass",
1210 ChoiceZone::Skip => "skip",
1211 }
1212}
1213
1214fn stage_row(slot: u8) -> &'static str {
1215 if slot < 3 {
1216 "center"
1217 } else {
1218 "back"
1219 }
1220}
1221
1222fn stage_slot_label(slot: u8) -> &'static str {
1223 match slot {
1224 0 => "center left",
1225 1 => "center middle",
1226 2 => "center right",
1227 3 => "back left",
1228 4 => "back right",
1229 _ => "unknown slot",
1230 }
1231}
1232
1233fn stage_status_name(status: StageStatus) -> &'static str {
1234 match status {
1235 StageStatus::Stand => "standing",
1236 StageStatus::Rest => "rested",
1237 StageStatus::Reverse => "reversed",
1238 }
1239}
1240
1241fn card_type_name(card_type: CardType) -> &'static str {
1242 match card_type {
1243 CardType::Character => "character",
1244 CardType::Event => "event",
1245 CardType::Climax => "climax",
1246 }
1247}
1248
1249fn color_name(color: CardColor) -> &'static str {
1250 match color {
1251 CardColor::Yellow => "yellow",
1252 CardColor::Green => "green",
1253 CardColor::Red => "red",
1254 CardColor::Blue => "blue",
1255 CardColor::Colorless => "colorless",
1256 }
1257}
1258
1259fn trigger_name(icon: TriggerIcon) -> &'static str {
1260 match icon {
1261 TriggerIcon::Soul => "soul",
1262 TriggerIcon::Shot => "shot",
1263 TriggerIcon::Bounce => "bounce",
1264 TriggerIcon::Draw => "draw",
1265 TriggerIcon::Choice => "choice",
1266 TriggerIcon::Pool => "pool",
1267 TriggerIcon::Treasure => "treasure",
1268 TriggerIcon::Gate => "gate",
1269 TriggerIcon::Standby => "standby",
1270 }
1271}
1272
1273fn attack_type_label(attack_type: AttackType) -> &'static str {
1274 match attack_type {
1275 AttackType::Frontal => "Frontal",
1276 AttackType::Side => "Side",
1277 AttackType::Direct => "Direct",
1278 }
1279}
1280
1281fn attack_type_short_label(attack_type: AttackType) -> &'static str {
1282 match attack_type {
1283 AttackType::Frontal => "Frontal",
1284 AttackType::Side => "Side",
1285 AttackType::Direct => "Direct",
1286 }
1287}