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.
24use serde::{Deserialize, Serialize};
One goal's most recently known outcome.
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,
Every goal's most recent outcome, by identity. A restart's fresh bot remembers nothing of its own; this is what survives instead.
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).
This goal's own outcome history, if it has failed or completed here before.
Record a new outcome: written to the log at once (if [Self::log_to]
was called) and kept as this identity's latest.
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}