weiss_core/env/
debug_validate.rs

1use anyhow::{anyhow, Result};
2
3use crate::db::CardId;
4use crate::legal::DecisionKind;
5use crate::state::{CardInstance, CardInstanceId, Phase};
6
7use super::debug_fingerprints;
8use super::{EngineErrorCode, FaultSource, GameEnv};
9
10impl GameEnv {
11    /// Whether expensive state validation should run on this step/reset path.
12    pub(crate) fn should_validate_state(&self) -> bool {
13        if cfg!(debug_assertions) {
14            return true;
15        }
16        self.validate_state_enabled
17    }
18
19    /// Run validation and latch an invariant fault when validation fails.
20    ///
21    /// Returns `true` when a fault was latched (validation failed).
22    pub(crate) fn maybe_validate_state(&mut self, context: &str) -> bool {
23        if !self.should_validate_state() {
24            return false;
25        }
26        if let Err(err) = self.validate_state() {
27            eprintln!("State validation failed in {context}: {err}");
28            let actor = self.decision.as_ref().map(|d| d.player);
29            self.latch_fault_deferred(
30                EngineErrorCode::InvariantViolation,
31                actor,
32                FaultSource::Step,
33            );
34            return true;
35        }
36        false
37    }
38
39    /// Run expensive invariants checks over the full game state.
40    ///
41    /// Intended for debug builds or diagnostics; returns a detailed error.
42    pub fn validate_state(&self) -> Result<()> {
43        use std::collections::{HashMap, HashSet};
44        let mut errors = Vec::new();
45
46        let mut counts: [HashMap<CardId, i32>; 2] = [HashMap::new(), HashMap::new()];
47        for (owner, owner_counts) in counts.iter_mut().enumerate() {
48            let deck_list = &self.config.deck_lists[owner];
49            for card in deck_list.iter().copied() {
50                *owner_counts.entry(card).or_insert(0) += 1;
51            }
52        }
53
54        fn consume(
55            counts: &mut [HashMap<CardId, i32>; 2],
56            errors: &mut Vec<String>,
57            owner: u8,
58            card: CardId,
59            zone: &str,
60        ) {
61            let owner_idx = owner as usize;
62            let entry = counts[owner_idx].entry(card).or_insert(0);
63            *entry -= 1;
64            if *entry < 0 {
65                errors.push(format!("Owner {owner} has extra card {card} in {zone}"));
66            }
67        }
68
69        let mut instance_ids: HashSet<CardInstanceId> = HashSet::new();
70        fn check_instance(
71            instance_ids: &mut HashSet<CardInstanceId>,
72            errors: &mut Vec<String>,
73            card: &CardInstance,
74            zone: &str,
75        ) {
76            if card.instance_id == 0 {
77                errors.push(format!("Card instance id 0 in {zone}"));
78                return;
79            }
80            if !instance_ids.insert(card.instance_id) {
81                errors.push(format!(
82                    "Duplicate instance id {} in {zone}",
83                    card.instance_id
84                ));
85            }
86        }
87
88        for zone_player in 0..2 {
89            let p = &self.state.players[zone_player];
90            for card in &p.deck {
91                consume(
92                    &mut counts,
93                    &mut errors,
94                    card.owner,
95                    card.id,
96                    &format!("p{zone_player} deck"),
97                );
98                check_instance(
99                    &mut instance_ids,
100                    &mut errors,
101                    card,
102                    &format!("p{zone_player} deck"),
103                );
104            }
105            for card in &p.hand {
106                consume(
107                    &mut counts,
108                    &mut errors,
109                    card.owner,
110                    card.id,
111                    &format!("p{zone_player} hand"),
112                );
113                check_instance(
114                    &mut instance_ids,
115                    &mut errors,
116                    card,
117                    &format!("p{zone_player} hand"),
118                );
119            }
120            for card in &p.waiting_room {
121                consume(
122                    &mut counts,
123                    &mut errors,
124                    card.owner,
125                    card.id,
126                    &format!("p{zone_player} waiting_room"),
127                );
128                check_instance(
129                    &mut instance_ids,
130                    &mut errors,
131                    card,
132                    &format!("p{zone_player} waiting_room"),
133                );
134            }
135            for card in &p.clock {
136                consume(
137                    &mut counts,
138                    &mut errors,
139                    card.owner,
140                    card.id,
141                    &format!("p{zone_player} clock"),
142                );
143                check_instance(
144                    &mut instance_ids,
145                    &mut errors,
146                    card,
147                    &format!("p{zone_player} clock"),
148                );
149            }
150            for card in &p.level {
151                consume(
152                    &mut counts,
153                    &mut errors,
154                    card.owner,
155                    card.id,
156                    &format!("p{zone_player} level"),
157                );
158                check_instance(
159                    &mut instance_ids,
160                    &mut errors,
161                    card,
162                    &format!("p{zone_player} level"),
163                );
164            }
165            for card in &p.stock {
166                consume(
167                    &mut counts,
168                    &mut errors,
169                    card.owner,
170                    card.id,
171                    &format!("p{zone_player} stock"),
172                );
173                check_instance(
174                    &mut instance_ids,
175                    &mut errors,
176                    card,
177                    &format!("p{zone_player} stock"),
178                );
179            }
180            for card in &p.memory {
181                consume(
182                    &mut counts,
183                    &mut errors,
184                    card.owner,
185                    card.id,
186                    &format!("p{zone_player} memory"),
187                );
188                check_instance(
189                    &mut instance_ids,
190                    &mut errors,
191                    card,
192                    &format!("p{zone_player} memory"),
193                );
194            }
195            for card in &p.climax {
196                consume(
197                    &mut counts,
198                    &mut errors,
199                    card.owner,
200                    card.id,
201                    &format!("p{zone_player} climax"),
202                );
203                check_instance(
204                    &mut instance_ids,
205                    &mut errors,
206                    card,
207                    &format!("p{zone_player} climax"),
208                );
209            }
210            for card in &p.resolution {
211                consume(
212                    &mut counts,
213                    &mut errors,
214                    card.owner,
215                    card.id,
216                    &format!("p{zone_player} resolution"),
217                );
218                check_instance(
219                    &mut instance_ids,
220                    &mut errors,
221                    card,
222                    &format!("p{zone_player} resolution"),
223                );
224            }
225            for (slot_idx, slot) in p.stage.iter().enumerate() {
226                if let Some(card) = slot.card {
227                    consume(
228                        &mut counts,
229                        &mut errors,
230                        card.owner,
231                        card.id,
232                        &format!("p{zone_player} stage[{slot_idx}]"),
233                    );
234                    check_instance(
235                        &mut instance_ids,
236                        &mut errors,
237                        &card,
238                        &format!("p{zone_player} stage[{slot_idx}]"),
239                    );
240                }
241                for marker in &slot.markers {
242                    consume(
243                        &mut counts,
244                        &mut errors,
245                        marker.owner,
246                        marker.id,
247                        &format!("p{zone_player} stage[{slot_idx}] marker"),
248                    );
249                    check_instance(
250                        &mut instance_ids,
251                        &mut errors,
252                        marker,
253                        &format!("p{zone_player} stage[{slot_idx}] marker"),
254                    );
255                }
256            }
257        }
258
259        for (owner, owner_counts) in counts.iter().enumerate() {
260            for (card, remaining) in owner_counts.iter() {
261                if *remaining != 0 {
262                    errors.push(format!(
263                        "Owner {owner} card {card} count mismatch ({remaining})"
264                    ));
265                }
266            }
267        }
268
269        if let Some(decision) = &self.decision {
270            if let Some(slot) = decision.focus_slot {
271                if slot as usize >= self.state.players[decision.player as usize].stage.len() {
272                    errors.push("Decision focus slot out of range".to_string());
273                }
274            }
275            match decision.kind {
276                DecisionKind::AttackDeclaration => {
277                    if self.state.turn.attack.is_some() {
278                        errors.push("Attack declaration while attack context active".to_string());
279                    }
280                }
281                DecisionKind::LevelUp => {
282                    if self.state.turn.pending_level_up.is_none() {
283                        errors.push("Level up decision without pending level".to_string());
284                    }
285                }
286                DecisionKind::Encore => {
287                    let has = self
288                        .state
289                        .turn
290                        .encore_queue
291                        .iter()
292                        .any(|r| r.player == decision.player);
293                    if !has {
294                        errors.push("Encore decision without reversed options".to_string());
295                    }
296                }
297                DecisionKind::TriggerOrder => {
298                    if self.state.turn.trigger_order.is_none() {
299                        errors.push("Trigger order decision without pending order".to_string());
300                    }
301                }
302                DecisionKind::Choice => {
303                    if let Some(choice) = &self.state.turn.choice {
304                        if choice.player != decision.player {
305                            errors.push("Choice decision player mismatch".to_string());
306                        }
307                    } else {
308                        errors.push("Choice decision without pending choice".to_string());
309                    }
310                }
311                _ => {}
312            }
313        }
314
315        if self.state.turn.attack.is_some() && self.state.turn.phase != Phase::Attack {
316            errors.push("Attack context outside Attack phase".to_string());
317        }
318
319        if errors.is_empty() {
320            return Ok(());
321        }
322
323        let state_hash = debug_fingerprints::state_fingerprint(&self.state);
324        let phase = self.state.turn.phase;
325        let attack_step = self.state.turn.attack.as_ref().map(|c| c.step);
326        let tail_len = 8usize;
327        let actions_tail: Vec<String> = self
328            .replay_actions
329            .iter()
330            .rev()
331            .take(tail_len)
332            .rev()
333            .map(|a| format!("{a:?}"))
334            .collect();
335        let decisions_tail: Vec<String> = self
336            .replay_steps
337            .iter()
338            .rev()
339            .take(tail_len)
340            .rev()
341            .map(|s| format!("{:?}/{:?}", s.decision_kind, s.actor))
342            .collect();
343        let fallback_action = self
344            .last_action_desc
345            .as_ref()
346            .map(|a| format!("{a:?}"))
347            .unwrap_or_else(|| "None".to_string());
348        let payload = format!(
349            "seed={}\nphase={:?}\nattack_step={:?}\nlast_action={}\nactions_tail={:?}\ndecisions_tail={:?}\nstate_hash={}",
350            self.episode_seed,
351            phase,
352            attack_step,
353            fallback_action,
354            actions_tail,
355            decisions_tail,
356            state_hash,
357        );
358        Err(anyhow!("{}\n{}", payload, errors.join("; ")))
359    }
360}