jevsnes.git / packages / zbanks / src / outcomes.rs

Per-goal outcomes - attempts, how each ended, when - persisted next to map_export.txt so a restart's fresh bot does not repeat every failed trip. A restart rebuilds the whole goal list from nothing (the C bot's state is globals, gone the instant the process is); without this, Jev is asked to pick between a goal that has already failed here and one it has never seen, with nothing in the request to tell them apart (research/zbanks-alttp.md, "Jev is not told a routine already failed here": frame 23806, Jev picked the Dam Chest routine again although it had already failed on earlier visits).

Identity is the exact string decisions::goal_choice::identity computes ("{kind}|{node}|{screen}") - this crate does not depend on decisions (that dependency runs the other way), so the format is duplicated by convention, not by a shared type; shim/host.c's own zb_note_outcome builds the identical string from the same three fields (ap_goal_type_names[g->type], g->node->name, zb_screen_name(g->node)) for exactly this reason.

19use std::collections::HashMap;
20use std::fs;
21use std::io::Write as _;
22use std::path::Path;
24use serde::{Deserialize, Serialize};

One goal's most recently known outcome.

27#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
28pub struct Outcome {
29    pub identity: String,

How many times this goal had been attempted when it left the list.

31    pub attempts: i32,

true if it left completed; false if it left permafailed (attempts > 3, ap_goal_fail's own threshold - the same test zb_watch_goals already makes in shim/host.c).

35    pub completed: bool,

The frame it left the list at.

37    pub frame: u32,
38}

Every goal's most recent outcome, by identity. A restart's fresh bot 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}
48impl Outcomes {

Load every outcome ever appended to path, keeping only the latest per identity (later lines win - append-only, never rewritten). A missing or unreadable file is not an error, the same tolerance decisions::Engine::load_history has for a fresh run; a line this 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    }

Open path for appending - every future [Self::record] is written through at once, so a killed process loses nothing already recorded (the same append-and-flush shape decisions::Engine's own 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    }

This goal's own outcome history, if it has failed or completed here before.

79    pub fn get(&self, identity: &str) -> Option<&Outcome> {
80        self.by_identity.get(identity)
81    }

Record a new outcome: written to the log at once (if [Self::log_to] 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}
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}