Skip to main content

aver/replay/
runtime.rs

1use super::JsonValue;
2use super::json_to_string;
3use super::session::{EffectRecord, RecordedOutcome};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
6pub enum EffectReplayMode {
7    #[default]
8    Normal,
9    Record,
10    Replay,
11}
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum ReplayFailure {
15    Exhausted {
16        effect_type: String,
17        position: usize,
18    },
19    Mismatch {
20        seq: u32,
21        expected: String,
22        got: String,
23    },
24    ArgsMismatch {
25        seq: u32,
26        effect_type: String,
27        expected: String,
28        got: String,
29    },
30    Unconsumed {
31        remaining: usize,
32    },
33}
34
35#[derive(Debug, Clone, Default)]
36pub struct EffectReplayState {
37    mode: EffectReplayMode,
38    recorded_effects: Vec<EffectRecord>,
39    replay_effects: Vec<EffectRecord>,
40    replay_pos: usize,
41    validate_replay_args: bool,
42    args_diff_count: usize,
43    /// Stack of independent product group ids for nested products.
44    group_stack: Vec<u32>,
45    /// Branch path stack for nested independent products.
46    /// E.g. [0, 1] means "branch 0 of outer product, branch 1 of inner product".
47    branch_stack: Vec<u32>,
48    /// Per-product stack of per-branch effect emission counters.
49    effect_count_stack: Vec<u32>,
50    /// Next group id to assign.
51    next_group_id: u32,
52    /// Indices within replay_effects consumed from current group (for unordered match).
53    group_consumed: Vec<usize>,
54    /// Optional safety cap for recording — `record_effect` stops
55    /// accepting new events beyond this count and callers can check
56    /// `record_full()` to bail out of a runaway loop (e.g. a game
57    /// that never reaches a quit condition). None = unlimited (CLI
58    /// default).
59    record_cap: Option<usize>,
60}
61
62impl EffectReplayState {
63    pub fn mode(&self) -> EffectReplayMode {
64        self.mode
65    }
66
67    pub fn set_normal(&mut self) {
68        self.mode = EffectReplayMode::Normal;
69        self.recorded_effects.clear();
70        self.replay_effects.clear();
71        self.replay_pos = 0;
72        self.validate_replay_args = false;
73        self.args_diff_count = 0;
74        self.reset_group_state();
75    }
76
77    pub fn start_recording(&mut self) {
78        self.mode = EffectReplayMode::Record;
79        self.recorded_effects.clear();
80        self.replay_effects.clear();
81        self.replay_pos = 0;
82        self.validate_replay_args = false;
83        self.args_diff_count = 0;
84        self.reset_group_state();
85    }
86
87    pub fn set_record_cap(&mut self, cap: Option<usize>) {
88        self.record_cap = cap;
89    }
90
91    pub fn record_full(&self) -> bool {
92        matches!(self.record_cap, Some(cap) if self.recorded_effects.len() >= cap)
93    }
94
95    pub fn start_replay(&mut self, effects: Vec<EffectRecord>, validate_args: bool) {
96        self.mode = EffectReplayMode::Replay;
97        self.replay_effects = effects;
98        self.replay_pos = 0;
99        self.validate_replay_args = validate_args;
100        self.recorded_effects.clear();
101        self.args_diff_count = 0;
102        self.reset_group_state();
103    }
104
105    pub fn take_recorded_effects(&mut self) -> Vec<EffectRecord> {
106        std::mem::take(&mut self.recorded_effects)
107    }
108
109    pub fn recorded_effects(&self) -> &[EffectRecord] {
110        &self.recorded_effects
111    }
112
113    pub fn replay_progress(&self) -> (usize, usize) {
114        (self.replay_pos, self.replay_effects.len())
115    }
116
117    pub fn args_diff_count(&self) -> usize {
118        self.args_diff_count
119    }
120
121    pub fn ensure_replay_consumed(&self) -> Result<(), ReplayFailure> {
122        if self.mode == EffectReplayMode::Replay && self.replay_pos < self.replay_effects.len() {
123            return Err(ReplayFailure::Unconsumed {
124                remaining: self.replay_effects.len() - self.replay_pos,
125            });
126        }
127        Ok(())
128    }
129
130    /// Enter an independent product group for recording. Returns the group id.
131    pub fn enter_group(&mut self) -> u32 {
132        self.next_group_id += 1;
133        let id = self.next_group_id;
134        self.group_stack.push(id);
135        self.branch_stack.push(0); // start at branch 0
136        self.effect_count_stack.push(0);
137        id
138    }
139
140    /// Exit the current independent product group.
141    pub fn exit_group(&mut self) {
142        self.group_stack.pop();
143        self.branch_stack.pop();
144        self.effect_count_stack.pop();
145    }
146
147    /// Set the current branch index within the current (innermost) product.
148    pub fn set_branch(&mut self, index: u32) {
149        if let Some(last) = self.branch_stack.last_mut() {
150            *last = index;
151        }
152        if let Some(last) = self.effect_count_stack.last_mut() {
153            *last = 0;
154        }
155    }
156
157    pub fn record_effect(
158        &mut self,
159        effect_type: &str,
160        args: Vec<JsonValue>,
161        outcome: RecordedOutcome,
162        caller_fn: &str,
163        source_line: usize,
164    ) {
165        let seq = self.recorded_effects.len() as u32 + 1;
166        self.recorded_effects.push(EffectRecord {
167            seq,
168            effect_type: effect_type.to_string(),
169            args,
170            outcome,
171            caller_fn: caller_fn.to_string(),
172            source_line,
173            group_id: self.group_stack.last().copied(),
174            branch_path: if self.branch_stack.is_empty() {
175                None
176            } else {
177                Some(self.current_branch_path())
178            },
179            effect_occurrence: if self.branch_stack.is_empty() {
180                None
181            } else {
182                self.current_effect_occurrence()
183            },
184        });
185        self.bump_effect_occurrence();
186    }
187
188    pub fn replay_effect(
189        &mut self,
190        effect_type: &str,
191        got_args: Option<Vec<JsonValue>>,
192    ) -> Result<RecordedOutcome, ReplayFailure> {
193        // Check if current position is inside a group — match by branch_path +
194        // effect_occurrence + type + args, not execution order
195        if self.replay_pos < self.replay_effects.len()
196            && let Some(gid) = self.replay_effects[self.replay_pos].group_id
197        {
198            return self.replay_effect_in_group(gid, effect_type, got_args);
199        }
200
201        // Sequential matching (original behavior)
202        if self.replay_pos >= self.replay_effects.len() {
203            return Err(ReplayFailure::Exhausted {
204                effect_type: effect_type.to_string(),
205                position: self.replay_pos + 1,
206            });
207        }
208
209        let record = self.replay_effects[self.replay_pos].clone();
210        if record.effect_type != effect_type {
211            return Err(ReplayFailure::Mismatch {
212                seq: record.seq,
213                expected: record.effect_type,
214                got: effect_type.to_string(),
215            });
216        }
217
218        if let Some(got_args) = got_args
219            && got_args != record.args
220        {
221            if self.validate_replay_args {
222                return Err(ReplayFailure::ArgsMismatch {
223                    seq: record.seq,
224                    effect_type: effect_type.to_string(),
225                    expected: json_to_string(&JsonValue::Array(record.args.clone())),
226                    got: json_to_string(&JsonValue::Array(got_args)),
227                });
228            }
229            self.args_diff_count += 1;
230        }
231
232        self.replay_pos += 1;
233        Ok(record.outcome)
234    }
235
236    /// Match an effect within a replay group by (branch_index, type, args), not position.
237    /// Falls back to (type, args) matching for recordings without branch_index.
238    fn replay_effect_in_group(
239        &mut self,
240        group_id: u32,
241        effect_type: &str,
242        got_args: Option<Vec<JsonValue>>,
243    ) -> Result<RecordedOutcome, ReplayFailure> {
244        // Find all effects in this group that haven't been consumed yet
245        let group_start = self.replay_pos;
246        let group_end = self.replay_effects[group_start..]
247            .iter()
248            .position(|e| e.group_id != Some(group_id))
249            .map(|offset| group_start + offset)
250            .unwrap_or(self.replay_effects.len());
251
252        // Search for a matching effect in the group.
253        // Prefer exact branch_index match; fall back to type+args only.
254        let current_bp = if self.branch_stack.is_empty() {
255            None
256        } else {
257            Some(self.current_branch_path())
258        };
259
260        let mut fallback_idx: Option<usize> = None;
261        for idx in group_start..group_end {
262            if self.group_consumed.contains(&idx) {
263                continue;
264            }
265            let record = &self.replay_effects[idx];
266            if record.effect_type != effect_type {
267                continue;
268            }
269
270            // Check args
271            let args_ok = match (&got_args, self.validate_replay_args) {
272                (Some(got), true) if *got != record.args => false,
273                (Some(got), false) if *got != record.args => {
274                    self.args_diff_count += 1;
275                    true
276                }
277                _ => true,
278            };
279            if !args_ok {
280                continue;
281            }
282
283            // Check branch_path + effect_occurrence: if both sides have them, must match.
284            // If recording lacks them (old format), accept as fallback.
285            let bp_match = match (&current_bp, &record.branch_path) {
286                (Some(got), Some(rec)) => {
287                    if got != rec {
288                        continue; // different branch, skip
289                    }
290                    true
291                }
292                _ => false, // one or both lack branch_path
293            };
294            if bp_match {
295                // Branch path matches — also check occurrence if available
296                let current_occ = self.current_effect_occurrence();
297                match (current_occ, record.effect_occurrence) {
298                    (Some(got), Some(rec)) if got == rec => {
299                        return self.consume_group_match(idx, group_start, group_end);
300                    }
301                    (Some(_), Some(_)) => continue, // same branch, different occurrence
302                    _ => {
303                        // Fallback: no occurrence info
304                        if fallback_idx.is_none() {
305                            fallback_idx = Some(idx);
306                        }
307                    }
308                }
309            } else if fallback_idx.is_none() {
310                fallback_idx = Some(idx);
311            }
312        }
313
314        // Use fallback if no exact branch match found
315        if let Some(idx) = fallback_idx {
316            return self.consume_group_match(idx, group_start, group_end);
317        }
318
319        // No match found in group
320        Err(ReplayFailure::Mismatch {
321            seq: self.replay_effects[group_start].seq,
322            expected: format!("one of group {} effects", group_id),
323            got: effect_type.to_string(),
324        })
325    }
326
327    fn consume_group_match(
328        &mut self,
329        idx: usize,
330        group_start: usize,
331        group_end: usize,
332    ) -> Result<RecordedOutcome, ReplayFailure> {
333        let outcome = self.replay_effects[idx].outcome.clone();
334        self.bump_effect_occurrence();
335        self.group_consumed.push(idx);
336        let group_size = group_end - group_start;
337        if self.group_consumed.len() >= group_size {
338            self.replay_pos = group_end;
339            self.group_consumed.clear();
340        }
341        Ok(outcome)
342    }
343
344    fn reset_group_state(&mut self) {
345        self.group_stack.clear();
346        self.branch_stack.clear();
347        self.effect_count_stack.clear();
348        self.next_group_id = 0;
349        self.group_consumed.clear();
350    }
351
352    fn current_branch_path(&self) -> String {
353        self.branch_stack
354            .iter()
355            .map(|i| i.to_string())
356            .collect::<Vec<_>>()
357            .join(".")
358    }
359
360    fn current_effect_occurrence(&self) -> Option<u32> {
361        self.effect_count_stack.last().copied()
362    }
363
364    fn bump_effect_occurrence(&mut self) {
365        if let Some(last) = self.effect_count_stack.last_mut() {
366            *last += 1;
367        }
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374
375    fn recorded_value(text: &str) -> RecordedOutcome {
376        RecordedOutcome::Value(JsonValue::String(text.to_string()))
377    }
378
379    #[test]
380    fn nested_groups_preserve_outer_effect_occurrence() {
381        let mut state = EffectReplayState::default();
382
383        state.start_recording();
384        state.enter_group();
385        state.set_branch(0);
386        state.record_effect(
387            "Console.print",
388            vec![],
389            RecordedOutcome::Value(JsonValue::Null),
390            "",
391            0,
392        );
393
394        state.enter_group();
395        state.set_branch(1);
396        state.record_effect(
397            "Console.print",
398            vec![],
399            RecordedOutcome::Value(JsonValue::Null),
400            "",
401            0,
402        );
403        state.exit_group();
404
405        state.record_effect(
406            "Console.print",
407            vec![],
408            RecordedOutcome::Value(JsonValue::Null),
409            "",
410            0,
411        );
412
413        let effects = state.take_recorded_effects();
414        assert_eq!(effects.len(), 3);
415        assert_eq!(effects[0].branch_path.as_deref(), Some("0"));
416        assert_eq!(effects[0].effect_occurrence, Some(0));
417        assert_eq!(effects[1].branch_path.as_deref(), Some("0.1"));
418        assert_eq!(effects[1].effect_occurrence, Some(0));
419        assert_eq!(effects[2].branch_path.as_deref(), Some("0"));
420        assert_eq!(effects[2].effect_occurrence, Some(1));
421    }
422
423    #[test]
424    fn start_replay_clears_group_state() {
425        let mut state = EffectReplayState::default();
426        state.start_recording();
427        state.enter_group();
428        state.set_branch(3);
429        state.record_effect(
430            "Console.print",
431            vec![],
432            RecordedOutcome::Value(JsonValue::Null),
433            "",
434            0,
435        );
436
437        state.start_replay(Vec::new(), true);
438
439        assert!(state.group_stack.is_empty());
440        assert!(state.branch_stack.is_empty());
441        assert!(state.effect_count_stack.is_empty());
442        assert!(state.group_consumed.is_empty());
443        assert_eq!(state.next_group_id, 0);
444        assert_eq!(state.args_diff_count, 0);
445    }
446
447    #[test]
448    fn replay_group_matching_uses_effect_occurrence() {
449        let mut state = EffectReplayState::default();
450        state.start_replay(
451            vec![
452                EffectRecord {
453                    seq: 1,
454                    effect_type: "Console.print".to_string(),
455                    args: vec![JsonValue::String("same".to_string())],
456                    outcome: recorded_value("first"),
457                    caller_fn: String::new(),
458                    source_line: 0,
459                    group_id: Some(1),
460                    branch_path: Some("0".to_string()),
461                    effect_occurrence: Some(0),
462                },
463                EffectRecord {
464                    seq: 2,
465                    effect_type: "Console.print".to_string(),
466                    args: vec![JsonValue::String("same".to_string())],
467                    outcome: recorded_value("second"),
468                    caller_fn: String::new(),
469                    source_line: 0,
470                    group_id: Some(1),
471                    branch_path: Some("0".to_string()),
472                    effect_occurrence: Some(1),
473                },
474            ],
475            true,
476        );
477
478        state.enter_group();
479        state.set_branch(0);
480
481        let first = state
482            .replay_effect(
483                "Console.print",
484                Some(vec![JsonValue::String("same".to_string())]),
485            )
486            .expect("first replay should match");
487        let second = state
488            .replay_effect(
489                "Console.print",
490                Some(vec![JsonValue::String("same".to_string())]),
491            )
492            .expect("second replay should match");
493
494        assert_eq!(first, recorded_value("first"));
495        assert_eq!(second, recorded_value("second"));
496    }
497}