jevsnes.git / packages / zbanks / src / outcomes.rs
1//! Per-goal outcomes - attempts, how each ended, when - persisted next to
2//! `map_export.txt` so a restart's fresh bot does not repeat every failed
3//! trip. A restart rebuilds the whole goal list from nothing (the C bot's
4//! state is globals, gone the instant the process is); without this, Jev
5//! is asked to pick between a goal that has already failed here and one it
6//! has never seen, with nothing in the request to tell them apart
7//! (`research/zbanks-alttp.md`, "Jev is not told a routine already failed
8//! here": frame 23806, Jev picked the Dam Chest routine again although it
9//! had already failed on earlier visits).
10//!
11//! Identity is the exact string `decisions::goal_choice::identity` computes
12//! (`"{kind}|{node}|{screen}"`) - this crate does not depend on
13//! `decisions` (that dependency runs the other way), so the format is
14//! duplicated by convention, not by a shared type; `shim/host.c`'s own
15//! `zb_note_outcome` builds the identical string from the same three
16//! fields (`ap_goal_type_names[g->type]`, `g->node->name`,
17//! `zb_screen_name(g->node)`) for exactly this reason.
18
19use std::collections::HashMap;
20use std::fs;
21use std::io::Write as _;
22use std::path::Path;
23
24use serde::{Deserialize, Serialize};
25
26/// One goal's most recently known outcome.
27#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
28pub struct Outcome {
29    pub identity: String,
30    /// How many times this goal had been attempted when it left the list.
31    pub attempts: i32,
32    /// `true` if it left completed; `false` if it left permafailed
33    /// (`attempts > 3`, `ap_goal_fail`'s own threshold - the same test
34    /// `zb_watch_goals` already makes in `shim/host.c`).
35    pub completed: bool,
36    /// The frame it left the list at.
37    pub frame: u32,
38}
39
40/// Every goal's most recent outcome, by identity. A restart's fresh bot
41/// remembers nothing of its own; this is what survives instead.
42#[derive(Default)]
43pub struct Outcomes {
44    by_identity: HashMap<String, Outcome>,
45    log: Option<fs::File>,
46}
47
48impl Outcomes {
49    /// Load every outcome ever appended to `path`, keeping only the latest
50    /// per identity (later lines win - append-only, never rewritten). A
51    /// missing or unreadable file is not an error, the same tolerance
52    /// `decisions::Engine::load_history` has for a fresh run; a line this
53    /// binary cannot parse (an older or newer shape) is skipped, not fatal.
54    pub fn load(path: &Path) -> Self {
55        let mut by_identity = HashMap::new();
56        if let Ok(text) = fs::read_to_string(path) {
57            for line in text.lines() {
58                if let Ok(o) = serde_json::from_str::<Outcome>(line) {
59                    by_identity.insert(o.identity.clone(), o);
60                }
61            }
62        }
63        Self { by_identity, log: None }
64    }
65
66    /// Open `path` for appending - every future [`Self::record`] is written
67    /// through at once, so a killed process loses nothing already recorded
68    /// (the same append-and-flush shape `decisions::Engine`'s own
69    /// `log_to`/`jev.jsonl` uses).
70    pub fn log_to(&mut self, path: &Path) -> Result<(), String> {
71        self.log = Some(
72            fs::OpenOptions::new().create(true).append(true).open(path).map_err(|e| format!("{}: {e}", path.display()))?,
73        );
74        Ok(())
75    }
76
77    /// This goal's own outcome history, if it has failed or completed here
78    /// before.
79    pub fn get(&self, identity: &str) -> Option<&Outcome> {
80        self.by_identity.get(identity)
81    }
82
83    /// Record a new outcome: written to the log at once (if [`Self::log_to`]
84    /// was called) and kept as this identity's latest.
85    pub fn record(&mut self, o: Outcome) {
86        if let Some(f) = &mut self.log {
87            if let Ok(line) = serde_json::to_string(&o) {
88                let _ = writeln!(f, "{line}");
89            }
90        }
91        self.by_identity.insert(o.identity.clone(), o);
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    fn outcome(identity: &str, attempts: i32, completed: bool, frame: u32) -> Outcome {
100        Outcome { identity: identity.to_owned(), attempts, completed, frame }
101    }
102
103    #[test]
104    fn a_missing_file_is_not_an_error() {
105        let o = Outcomes::load(Path::new("/nonexistent/path/goal_outcomes.jsonl"));
106        assert!(o.get("PICKUP|pot|Yard").is_none());
107    }
108
109    #[test]
110    fn recording_then_getting_returns_the_latest() {
111        let mut o = Outcomes::default();
112        o.record(outcome("SCRIPT|Dam|Dam Chest", 1, false, 100));
113        o.record(outcome("SCRIPT|Dam|Dam Chest", 2, false, 500));
114        let got = o.get("SCRIPT|Dam|Dam Chest").expect("recorded");
115        assert_eq!(got.attempts, 2);
116        assert_eq!(got.frame, 500);
117        assert!(!got.completed);
118    }
119
120    #[test]
121    fn a_later_line_wins_on_load_not_the_first() {
122        let dir = std::env::temp_dir().join(format!("jev-outcomes-test-{}", std::process::id()));
123        let _ = fs::remove_dir_all(&dir);
124        fs::create_dir_all(&dir).unwrap();
125        let path = dir.join("goal_outcomes.jsonl");
126        let mut w = Outcomes::default();
127        w.log_to(&path).unwrap();
128        w.record(outcome("PICKUP|pot 0x71|Sahasrahla's Hut", 1, false, 10));
129        w.record(outcome("PICKUP|pot 0x71|Sahasrahla's Hut", 2, true, 20));
130
131        let loaded = Outcomes::load(&path);
132        let got = loaded.get("PICKUP|pot 0x71|Sahasrahla's Hut").expect("loaded");
133        assert_eq!(got.attempts, 2);
134        assert!(got.completed);
135
136        let _ = fs::remove_dir_all(&dir);
137    }
138
139    #[test]
140    fn an_unparseable_line_is_skipped_not_fatal() {
141        let dir = std::env::temp_dir().join(format!("jev-outcomes-test-bad-{}", std::process::id()));
142        let _ = fs::remove_dir_all(&dir);
143        fs::create_dir_all(&dir).unwrap();
144        let path = dir.join("goal_outcomes.jsonl");
145        fs::write(&path, "not json\n{\"identity\":\"A|b|C\",\"attempts\":1,\"completed\":true,\"frame\":7}\n").unwrap();
146
147        let loaded = Outcomes::load(&path);
148        assert_eq!(loaded.get("A|b|C").map(|o| o.frame), Some(7));
149
150        let _ = fs::remove_dir_all(&dir);
151    }
152}