Skip to main content

weiss_core/env/
human_view.rs

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    /// Build a redacted, JSON-serialized view of the current decision for human UIs.
224    ///
225    /// Legal actions are decoded only from the current cached legal id list, preserving
226    /// the simulator's decision-boundary action contract and ordering.
227    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 &param.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}