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