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
15pub const FINGERPRINT_ALGO: &str = "postcard+blake3+u64le v1";
17
18pub 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
26pub 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 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
63pub fn config_fingerprint(config: &EnvConfig, curriculum: &CurriculumConfig) -> u64 {
65 let canonical = CanonicalConfigForHash::from_config(config, curriculum);
66 hash_postcard(&canonical)
67}
68
69pub fn state_fingerprint(state: &GameState) -> u64 {
71 let canonical = CanonicalStateForHash::from_state(state);
72 hash_postcard(&canonical)
73}
74
75pub 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}