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}