weiss_core/
fingerprint.rs

1use serde::Serialize;
2
3use crate::config::{
4    CurriculumConfig, EndConditionPolicy, EnvConfig, ErrorPolicy, ObservationVisibility,
5    RewardConfig,
6};
7use crate::db::CardId;
8use crate::effects::ReplacementSpec;
9use crate::events::Event;
10use crate::state::{
11    AttackContext, ChoiceState, GameState, PendingTrigger, PlayerState, PriorityState, StackItem,
12    StackOrderState, TargetSelectionState, TerminalResult, TimingWindow, TurnState,
13};
14
15/// Fingerprint algorithm identifier.
16pub const FINGERPRINT_ALGO: &str = "postcard+blake3+u64le v1";
17
18/// Hash raw bytes into a stable 64-bit fingerprint.
19pub fn hash_bytes(bytes: &[u8]) -> u64 {
20    let hash = blake3::hash(bytes);
21    let mut out = [0u8; 8];
22    out.copy_from_slice(&hash.as_bytes()[..8]);
23    u64::from_le_bytes(out)
24}
25
26/// Hash a serializable value using postcard + blake3.
27pub fn hash_postcard<T: Serialize + ?Sized>(value: &T) -> u64 {
28    match postcard::to_allocvec(value) {
29        Ok(bytes) => hash_bytes(&bytes),
30        Err(err) => {
31            debug_assert!(
32                false,
33                "fingerprint serialization failed for {}: {err}",
34                std::any::type_name::<T>()
35            );
36            match serde_json::to_vec(value) {
37                Ok(json_bytes) => {
38                    eprintln!(
39                        "postcard serialization failed for {}, using JSON fallback: {err}",
40                        std::any::type_name::<T>()
41                    );
42                    hash_bytes(&json_bytes)
43                }
44                Err(json_err) => {
45                    eprintln!(
46                        "fingerprint serialization failed for {}: postcard={err}; json={json_err}",
47                        std::any::type_name::<T>()
48                    );
49                    // Last-resort fallback when both postcard and JSON serialization fail.
50                    let mut fallback = b"fingerprint-serialization-error:".to_vec();
51                    fallback.extend_from_slice(std::any::type_name::<T>().as_bytes());
52                    fallback.extend_from_slice(b":postcard=");
53                    fallback.extend_from_slice(err.to_string().as_bytes());
54                    fallback.extend_from_slice(b":json=");
55                    fallback.extend_from_slice(json_err.to_string().as_bytes());
56                    hash_bytes(&fallback)
57                }
58            }
59        }
60    }
61}
62
63/// Compute a stable fingerprint for env config + curriculum.
64pub fn config_fingerprint(config: &EnvConfig, curriculum: &CurriculumConfig) -> u64 {
65    let canonical = CanonicalConfigForHash::from_config(config, curriculum);
66    hash_postcard(&canonical)
67}
68
69/// Compute a stable fingerprint for a game state.
70pub fn state_fingerprint(state: &GameState) -> u64 {
71    let canonical = CanonicalStateForHash::from_state(state);
72    hash_postcard(&canonical)
73}
74
75/// Compute a stable fingerprint for an event stream.
76pub fn events_fingerprint(events: &[Event]) -> u64 {
77    hash_postcard(events)
78}
79
80#[derive(Clone, Debug, Serialize)]
81struct CanonicalConfigForHash {
82    env: CanonicalEnvConfig,
83    curriculum: CanonicalCurriculumConfig,
84}
85
86impl CanonicalConfigForHash {
87    fn from_config(config: &EnvConfig, curriculum: &CurriculumConfig) -> Self {
88        Self {
89            env: CanonicalEnvConfig::from_config(config),
90            curriculum: CanonicalCurriculumConfig::from_curriculum(curriculum),
91        }
92    }
93}
94
95#[derive(Clone, Debug, Serialize)]
96struct CanonicalEnvConfig {
97    deck_lists: [Vec<CardId>; 2],
98    deck_ids: [u32; 2],
99    max_decisions: u32,
100    max_ticks: u32,
101    reward: RewardConfig,
102    error_policy: ErrorPolicy,
103    observation_visibility: ObservationVisibility,
104    end_condition_policy: EndConditionPolicy,
105}
106
107impl CanonicalEnvConfig {
108    fn from_config(config: &EnvConfig) -> Self {
109        Self {
110            deck_lists: config.deck_lists.clone(),
111            deck_ids: config.deck_ids,
112            max_decisions: config.max_decisions,
113            max_ticks: config.max_ticks,
114            reward: config.reward.clone(),
115            error_policy: config.error_policy,
116            observation_visibility: config.observation_visibility,
117            end_condition_policy: config.end_condition_policy.clone(),
118        }
119    }
120}
121
122#[derive(Clone, Debug, Serialize)]
123struct CanonicalCurriculumConfig {
124    allowed_card_sets: Vec<String>,
125    allow_character: bool,
126    allow_event: bool,
127    allow_climax: bool,
128    enable_clock_phase: bool,
129    enable_climax_phase: bool,
130    enable_side_attacks: bool,
131    enable_direct_attacks: bool,
132    enable_counters: bool,
133    enable_triggers: bool,
134    enable_trigger_soul: bool,
135    enable_trigger_draw: bool,
136    enable_trigger_shot: bool,
137    enable_trigger_bounce: bool,
138    enable_trigger_treasure: bool,
139    enable_trigger_gate: bool,
140    enable_trigger_standby: bool,
141    enable_backup: bool,
142    enable_encore: bool,
143    enable_refresh_penalty: bool,
144    enable_level_up_choice: bool,
145    enable_activated_abilities: bool,
146    enable_continuous_modifiers: bool,
147    enable_approx_effects: bool,
148    enable_priority_windows: bool,
149    enable_visibility_policies: bool,
150    use_alternate_end_conditions: bool,
151    priority_autopick_single_action: bool,
152    priority_allow_pass: bool,
153    strict_priority_mode: bool,
154    enable_legacy_cost_order: bool,
155    enable_legacy_shot_damage_step_only: bool,
156    reduced_stage_mode: bool,
157    enforce_color_requirement: bool,
158    enforce_cost_requirement: bool,
159    allow_concede: bool,
160    reveal_opponent_hand_stock_counts: bool,
161    memory_is_public: bool,
162}
163
164impl CanonicalCurriculumConfig {
165    fn from_curriculum(curriculum: &CurriculumConfig) -> Self {
166        let mut allowed_card_sets = curriculum.allowed_card_sets.clone();
167        allowed_card_sets.sort();
168        allowed_card_sets.dedup();
169        Self {
170            allowed_card_sets,
171            allow_character: curriculum.allow_character,
172            allow_event: curriculum.allow_event,
173            allow_climax: curriculum.allow_climax,
174            enable_clock_phase: curriculum.enable_clock_phase,
175            enable_climax_phase: curriculum.enable_climax_phase,
176            enable_side_attacks: curriculum.enable_side_attacks,
177            enable_direct_attacks: curriculum.enable_direct_attacks,
178            enable_counters: curriculum.enable_counters,
179            enable_triggers: curriculum.enable_triggers,
180            enable_trigger_soul: curriculum.enable_trigger_soul,
181            enable_trigger_draw: curriculum.enable_trigger_draw,
182            enable_trigger_shot: curriculum.enable_trigger_shot,
183            enable_trigger_bounce: curriculum.enable_trigger_bounce,
184            enable_trigger_treasure: curriculum.enable_trigger_treasure,
185            enable_trigger_gate: curriculum.enable_trigger_gate,
186            enable_trigger_standby: curriculum.enable_trigger_standby,
187            enable_backup: curriculum.enable_backup,
188            enable_encore: curriculum.enable_encore,
189            enable_refresh_penalty: curriculum.enable_refresh_penalty,
190            enable_level_up_choice: curriculum.enable_level_up_choice,
191            enable_activated_abilities: curriculum.enable_activated_abilities,
192            enable_continuous_modifiers: curriculum.enable_continuous_modifiers,
193            enable_approx_effects: curriculum.enable_approx_effects,
194            enable_priority_windows: curriculum.enable_priority_windows,
195            enable_visibility_policies: curriculum.enable_visibility_policies,
196            use_alternate_end_conditions: curriculum.use_alternate_end_conditions,
197            priority_autopick_single_action: curriculum.priority_autopick_single_action,
198            priority_allow_pass: curriculum.priority_allow_pass,
199            strict_priority_mode: curriculum.strict_priority_mode,
200            enable_legacy_cost_order: curriculum.enable_legacy_cost_order,
201            enable_legacy_shot_damage_step_only: curriculum.enable_legacy_shot_damage_step_only,
202            reduced_stage_mode: curriculum.reduced_stage_mode,
203            enforce_color_requirement: curriculum.enforce_color_requirement,
204            enforce_cost_requirement: curriculum.enforce_cost_requirement,
205            allow_concede: curriculum.allow_concede,
206            reveal_opponent_hand_stock_counts: curriculum.reveal_opponent_hand_stock_counts,
207            memory_is_public: curriculum.memory_is_public,
208        }
209    }
210}
211
212#[derive(Clone, Debug, Serialize)]
213struct CanonicalStateForHash {
214    players: [CanonicalPlayerState; 2],
215    reveal_history: [crate::state::RevealHistory; 2],
216    turn: CanonicalTurnState,
217    rng_state: u64,
218    modifiers: Vec<crate::state::ModifierInstance>,
219    next_modifier_id: u32,
220    replacements: Vec<ReplacementSpec>,
221    next_replacement_insertion: u32,
222    terminal: Option<TerminalResult>,
223}
224
225impl CanonicalStateForHash {
226    fn from_state(state: &GameState) -> Self {
227        Self {
228            players: [
229                CanonicalPlayerState::from_player(&state.players[0]),
230                CanonicalPlayerState::from_player(&state.players[1]),
231            ],
232            reveal_history: state.reveal_history.clone(),
233            turn: CanonicalTurnState::from_turn(&state.turn),
234            rng_state: state.rng.state(),
235            modifiers: state.modifiers.clone(),
236            next_modifier_id: state.next_modifier_id,
237            replacements: state.replacements.clone(),
238            next_replacement_insertion: state.next_replacement_insertion,
239            terminal: state.terminal,
240        }
241    }
242}
243
244#[derive(Clone, Debug, Serialize)]
245struct CanonicalPlayerState {
246    deck: Vec<crate::state::CardInstance>,
247    hand: Vec<crate::state::CardInstance>,
248    waiting_room: Vec<crate::state::CardInstance>,
249    clock: Vec<crate::state::CardInstance>,
250    level: Vec<crate::state::CardInstance>,
251    stock: Vec<crate::state::CardInstance>,
252    memory: Vec<crate::state::CardInstance>,
253    climax: Vec<crate::state::CardInstance>,
254    resolution: Vec<crate::state::CardInstance>,
255    stage: [crate::state::StageSlot; 5],
256}
257
258impl CanonicalPlayerState {
259    fn from_player(player: &PlayerState) -> Self {
260        Self {
261            deck: player.deck.clone(),
262            hand: player.hand.clone(),
263            waiting_room: player.waiting_room.clone(),
264            clock: player.clock.clone(),
265            level: player.level.clone(),
266            stock: player.stock.clone(),
267            memory: player.memory.clone(),
268            climax: player.climax.clone(),
269            resolution: player.resolution.clone(),
270            stage: player.stage.clone(),
271        }
272    }
273}
274
275#[derive(Clone, Debug, Serialize)]
276struct CanonicalTurnState {
277    active_player: u8,
278    starting_player: u8,
279    turn_number: u32,
280    phase: crate::state::Phase,
281    mulligan_done: [bool; 2],
282    mulligan_selected: [u64; 2],
283    main_passed: bool,
284    decision_count: u32,
285    tick_count: u32,
286    attack: Option<AttackContext>,
287    attack_subphase_count: u8,
288    pending_level_up: Option<u8>,
289    encore_queue: Vec<crate::state::EncoreRequest>,
290    encore_step_player: Option<u8>,
291    pending_triggers: Vec<PendingTrigger>,
292    trigger_order: Option<crate::state::TriggerOrderState>,
293    choice: Option<ChoiceState>,
294    target_selection: Option<TargetSelectionState>,
295    pending_cost: Option<crate::state::CostPaymentState>,
296    priority: Option<PriorityState>,
297    stack: Vec<StackItem>,
298    pending_stack_groups: Vec<StackOrderState>,
299    stack_order: Option<StackOrderState>,
300    next_trigger_id: u32,
301    next_trigger_group_id: u32,
302    next_choice_id: u32,
303    next_stack_group_id: u32,
304    next_damage_event_id: u32,
305    next_effect_instance_id: u32,
306    active_window: Option<TimingWindow>,
307    end_phase_window_done: bool,
308    end_phase_discard_done: bool,
309    end_phase_climax_done: bool,
310    end_phase_cleanup_done: bool,
311    encore_window_done: bool,
312    pending_losses: [bool; 2],
313    damage_resolution_target: Option<u8>,
314    cost_payment_depth: u8,
315    pending_resolution_cleanup: Vec<(u8, crate::state::CardInstanceId)>,
316    cannot_use_auto_encore: [bool; 2],
317    rule_overrides: Vec<crate::effects::RuleOverrideKind>,
318    phase_step: u8,
319    attack_phase_begin_done: bool,
320    attack_decl_check_done: bool,
321    encore_begin_done: bool,
322    end_phase_pending: bool,
323}
324
325impl CanonicalTurnState {
326    fn from_turn(turn: &TurnState) -> Self {
327        Self {
328            active_player: turn.active_player,
329            starting_player: turn.starting_player,
330            turn_number: turn.turn_number,
331            phase: turn.phase,
332            mulligan_done: turn.mulligan_done,
333            mulligan_selected: turn.mulligan_selected,
334            main_passed: turn.main_passed,
335            decision_count: turn.decision_count,
336            tick_count: turn.tick_count,
337            attack: turn.attack.clone(),
338            attack_subphase_count: turn.attack_subphase_count,
339            pending_level_up: turn.pending_level_up,
340            encore_queue: turn.encore_queue.clone(),
341            encore_step_player: turn.encore_step_player,
342            pending_triggers: turn.pending_triggers.clone(),
343            trigger_order: turn.trigger_order.clone(),
344            choice: turn.choice.clone(),
345            target_selection: turn.target_selection.clone(),
346            pending_cost: turn.pending_cost.clone(),
347            priority: turn.priority.clone(),
348            stack: turn.stack.clone(),
349            pending_stack_groups: turn.pending_stack_groups.iter().cloned().collect(),
350            stack_order: turn.stack_order.clone(),
351            next_trigger_id: turn.next_trigger_id,
352            next_trigger_group_id: turn.next_trigger_group_id,
353            next_choice_id: turn.next_choice_id,
354            next_stack_group_id: turn.next_stack_group_id,
355            next_damage_event_id: turn.next_damage_event_id,
356            next_effect_instance_id: turn.next_effect_instance_id,
357            active_window: turn.active_window,
358            end_phase_window_done: turn.end_phase_window_done,
359            end_phase_discard_done: turn.end_phase_discard_done,
360            end_phase_climax_done: turn.end_phase_climax_done,
361            end_phase_cleanup_done: turn.end_phase_cleanup_done,
362            encore_window_done: turn.encore_window_done,
363            pending_losses: turn.pending_losses,
364            damage_resolution_target: turn.damage_resolution_target,
365            cost_payment_depth: turn.cost_payment_depth,
366            pending_resolution_cleanup: turn.pending_resolution_cleanup.clone(),
367            cannot_use_auto_encore: turn.cannot_use_auto_encore,
368            rule_overrides: turn.rule_overrides.clone(),
369            phase_step: turn.phase_step,
370            attack_phase_begin_done: turn.attack_phase_begin_done,
371            attack_decl_check_done: turn.attack_decl_check_done,
372            encore_begin_done: turn.encore_begin_done,
373            end_phase_pending: turn.end_phase_pending,
374        }
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381
382    #[test]
383    fn no_hashmap_leakage_regression() {
384        let config_name = std::any::type_name::<CanonicalConfigForHash>();
385        let state_name = std::any::type_name::<CanonicalStateForHash>();
386        assert!(!config_name.contains("HashMap"));
387        assert!(!config_name.contains("HashSet"));
388        assert!(!state_name.contains("HashMap"));
389        assert!(!state_name.contains("HashSet"));
390    }
391}