1//! Jev at the zbanks bot's goal choice - the first [`crate::Decision`] this 2//! project built, and the one this module implements. 3//! 4//! The C bot picks its next goal as the one with the lowest score 5//! (`ap_goal_evaluate`, ap_plan.c). Carried patch 0002 lets a host make that 6//! choice instead, and `zbanks::Bot::set_goal_chooser` offers it the 7//! satisfiable goals scored within a margin of the lowest (at most six, 8//! upstream's pick first - `IDS` below relies on that cap). [`Shared`] is 9//! the chooser that asks Jev: the options said as sentences ([`words`]), one 10//! Choice question, and a pick sampled from Jev's distribution the way the 11//! digest's `_sample_goal` does it (research/jev-api-digest.md, "How it 12//! samples"). 13//! 14//! Goals nobody could tell apart from the words are one option 15//! ([`words::offers`]); when that leaves fewer than two, [`GoalChoice`]'s 16//! `question` returns `None` and the engine falls back without asking. 17//! 18//! ```rust,ignore 19//! let client = jev_http::Jev::from_env("native", decisions::model()).expect("key")?; 20//! let mut chooser = decisions::goal_choice::Chooser::new(client, decisions::goal_choice::Config::default())?; 21//! chooser.load_history(&log_path)?; // before log_to, so a restart keeps the panel's history 22//! chooser.log_to(&log_path)?; 23//! let chooser = decisions::goal_choice::Shared::new(chooser); 24//! bot.set_goal_chooser(Some(Box::new(chooser.clone())), decisions::goal_choice::Config::default().margin); 25//! // chooser.0.borrow().engine.last / .engine.totals / .engine.history() / .overrides: 26//! // the last decision, running totals, the bounded newest-last history the 27//! // Jev panel and the `jev_history` MCP tool read, and how many choices 28//! // differed from upstream's own pick. 29//! ``` 30 31pub mod words; 32 33use std::cell::RefCell; 34use std::path::Path; 35use std::rc::Rc; 36 37use jev_protocol::{Choice, ChoiceAnswer, Json, Key, Questions}; 38use serde::{Deserialize, Serialize}; 39use serde_json::json; 40use zbanks::{GoalChooser, GoalOption, Situation}; 41 42use crate::{Ask, Decision, Engine, Record}; 43 44/// A JSON value as jev-protocol's `Json`: `serde_json`'s own output, so 45/// always JSON. 46fn json_of(value: &serde_json::Value) -> Json { 47 Json::verbatim(&value.to_string()).expect("serde_json writes JSON") 48} 49 50fn identity(option: &GoalOption) -> String { 51 format!("{}|{}|{}", option.kind, option.node, option.screen) 52} 53 54/// The option names sent to Jev. Fixed at six because the C shim never 55/// offers more (`set_goal_chooser`'s own margin cap) - `question` relies on 56/// this to guarantee `Choice::new` never fails for having duplicate names. 57const IDS: [&str; 6] = ["A", "B", "C", "D", "E", "F"]; 58 59/// Every dungeon/area bit `link_compass`/`link_bigkey`/`link_dungeon_map` 60/// agree on (`research/alttp-ram-map.md`, "$7EF364"/"$7EF366": SET 1 is the 61/// low byte, SET 2 the high byte of the 16-bit field this reads as one 62/// word), used here to name which dungeons' big key Link holds. 63const DUNGEON_BITS: [(u16, &str); 14] = [ 64 (0x0004, "Ganon's Tower"), 65 (0x0008, "Turtle Rock"), 66 (0x0010, "Thieves' Town"), 67 (0x0020, "Tower of Hera"), 68 (0x0040, "Ice Palace"), 69 (0x0080, "Skull Woods"), 70 (0x0100, "Misery Mire"), 71 (0x0200, "Palace of Darkness"), 72 (0x0400, "Swamp Palace"), 73 (0x0800, "Agahnim's Tower"), 74 (0x1000, "Desert Palace"), 75 (0x2000, "Eastern Palace"), 76 (0x4000, "Hyrule Castle"), 77 (0x8000, "Sewers"), 78]; 79 80/// The parts of SRAM/WRAM a goal choice's wording needs beyond what the 81/// bot's own goal records say - inventory, hearts, pendants, crystals, and 82/// dungeon keys. This module never reaches into `packages/zbanks` or the 83/// console itself to get it (that would mean touching the `GoalChooser` 84/// trait's signature, which lives there, not here): instead 85/// [`Chooser::set_snapshot`] is refreshed once a frame by whoever already 86/// reads work RAM for another reason (`apps/native`'s tick, before 87/// `Bot::tick`, since a goal choice can fire synchronously inside it, and 88/// `apps/zbanks`'s headless loop the same way). 89/// 90/// Kept as named, derived values rather than raw WRAM bytes, so the address 91/// knowledge (`research/alttp-ram-map.md`, "$7EF340" onward) stays in this 92/// one `read`, not spread into every caller. 93#[derive(Clone, Copy, Debug, Default, PartialEq)] 94pub struct Snapshot { 95 pub hearts: f32, 96 pub heart_pieces: u8, 97 pub sword: u8, 98 pub shield: u8, 99 pub bow: u8, 100 pub hookshot: bool, 101 pub bombs: u8, 102 pub fire_rod: bool, 103 pub ice_rod: bool, 104 pub bombos: bool, 105 pub ether: bool, 106 pub quake: bool, 107 pub hammer: bool, 108 pub flute: u8, 109 pub bug_net: bool, 110 pub book_of_mudora: bool, 111 pub cane_somaria: bool, 112 pub cane_byrna: bool, 113 pub cape: bool, 114 pub mirror: bool, 115 pub gloves: u8, 116 pub boots: bool, 117 pub flippers: bool, 118 pub moon_pearl: bool, 119 pub arrows: u8, 120 /// `$7EF374`, bits `0x01`=Wisdom(red), `0x02`=Power(blue), 121 /// `0x04`=Courage(green) - jpdasm `symbols_sram.asm:779-781`. 122 pub pendants: u8, 123 /// `$7EF37A`, one bit per dungeon (`research/alttp-ram-map.md`, 124 /// "$7EF37A"); only the COUNT is used today, since Jev is never asked 125 /// to plan a specific crystal dungeon yet. 126 pub crystals: u8, 127 /// `$7EF36F`: the CURRENT dungeon's small-key count only - there is no 128 /// confirmed per-dungeon small-key array to read (the doc's own 129 /// `link_keys_earned_per_dungeon[]` entry is explicitly "not confirmed 130 /// beyond base"), so this is honestly partial rather than a guessed 131 /// full breakdown. 132 pub current_dungeon_small_keys: u8, 133 /// `$7EF366`/`367`, [`DUNGEON_BITS`]'s layout: which dungeons' big key 134 /// Link holds. 135 pub big_keys: u16, 136} 137 138impl Snapshot { 139 pub fn read(wram: &[u8; 0x20000]) -> Self { 140 let word = |at: usize| u16::from_le_bytes([wram[at], wram[at + 1]]); 141 let flag = |at: usize| wram[at] != 0; 142 Self { 143 hearts: f32::from(wram[0xF36D]) / 8.0, 144 heart_pieces: wram[0xF36B], 145 sword: wram[0xF359], 146 shield: wram[0xF35A], 147 bow: wram[0xF340], 148 hookshot: flag(0xF342), 149 bombs: wram[0xF343], 150 fire_rod: flag(0xF345), 151 ice_rod: flag(0xF346), 152 bombos: flag(0xF347), 153 ether: flag(0xF348), 154 quake: flag(0xF349), 155 hammer: flag(0xF34B), 156 flute: wram[0xF34C], 157 bug_net: flag(0xF34D), 158 book_of_mudora: flag(0xF34E), 159 cane_somaria: flag(0xF350), 160 cane_byrna: flag(0xF351), 161 cape: flag(0xF352), 162 mirror: flag(0xF353), 163 gloves: wram[0xF354], 164 boots: flag(0xF355), 165 flippers: flag(0xF356), 166 moon_pearl: flag(0xF357), 167 arrows: wram[0xF377], 168 pendants: wram[0xF374], 169 crystals: wram[0xF37A], 170 current_dungeon_small_keys: wram[0xF36F], 171 big_keys: word(0xF366), 172 } 173 } 174 175 /// Everything Link is holding, as short phrases - only what he actually 176 /// has, so this grows with the run instead of listing every possible 177 /// item as absent. 178 fn inventory(&self) -> Vec<&'static str> { 179 let mut items = Vec::new(); 180 if self.sword > 0 { 181 items.push("a sword"); 182 } 183 if self.shield > 0 { 184 items.push("a shield"); 185 } 186 if self.bow > 0 { 187 items.push("the bow"); 188 } 189 if self.hookshot { 190 items.push("the hookshot"); 191 } 192 if self.bombs > 0 { 193 items.push("bombs"); 194 } 195 if self.fire_rod { 196 items.push("the fire rod"); 197 } 198 if self.ice_rod { 199 items.push("the ice rod"); 200 } 201 if self.bombos { 202 items.push("the Bombos medallion"); 203 } 204 if self.ether { 205 items.push("the Ether medallion"); 206 } 207 if self.quake { 208 items.push("the Quake medallion"); 209 } 210 if self.hammer { 211 items.push("the hammer"); 212 } 213 if self.flute > 1 { 214 items.push("the flute"); 215 } 216 if self.bug_net { 217 items.push("the bug net"); 218 } 219 if self.book_of_mudora { 220 items.push("the Book of Mudora"); 221 } 222 if self.cane_somaria { 223 items.push("the Cane of Somaria"); 224 } 225 if self.cane_byrna { 226 items.push("the Cane of Byrna"); 227 } 228 if self.cape { 229 items.push("the Magic Cape"); 230 } 231 if self.mirror { 232 items.push("the Magic Mirror"); 233 } 234 if self.gloves > 0 { 235 items.push("power gloves"); 236 } 237 if self.boots { 238 items.push("the Pegasus Boots"); 239 } 240 if self.flippers { 241 items.push("flippers"); 242 } 243 if self.moon_pearl { 244 items.push("the Moon Pearl"); 245 } 246 items 247 } 248 249 fn pendant_names(&self) -> Vec<&'static str> { 250 [(0x01, "Wisdom"), (0x02, "Power"), (0x04, "Courage")] 251 .into_iter() 252 .filter(|&(bit, _)| self.pendants & bit != 0) 253 .map(|(_, name)| name) 254 .collect() 255 } 256 257 fn big_key_names(&self) -> Vec<&'static str> { 258 DUNGEON_BITS.iter().filter(|&&(bit, _)| self.big_keys & bit != 0).map(|&(_, name)| name).collect() 259 } 260 261 /// How much of the game's progress this save shows: NOT decisions made 262 /// (which reset to zero on every restart - the bug this replaces, user 263 /// 2026-09-22, "goals_already_done: 0" on a run well past the start), 264 /// but items, pendants, crystals, big keys and heart pieces actually 265 /// held, read fresh from SRAM every time and therefore correct across 266 /// a restart. 267 fn progress_count(&self) -> u32 { 268 self.inventory().len() as u32 269 + u32::from(self.pendants.count_ones()) 270 + u32::from(self.crystals.count_ones()) 271 + u32::from(self.big_keys.count_ones()) 272 + u32::from(self.heart_pieces) 273 } 274} 275 276/// How the chooser behaves. The defaults are what was verified. 277#[derive(Clone, Copy, Debug)] 278pub struct Config { 279 /// How far above upstream's lowest score a goal may be and still be 280 /// offered, in the bot's score units (path cost, roughly pixels walked). 281 /// Passed straight to `zbanks::Bot::set_goal_chooser`; the engine below 282 /// never reads it. 283 pub margin: i32, 284 /// A set of options asked about within this many frames is not asked 285 /// again; the last answer for it is resampled instead. 286 pub reask_frames: u32, 287 /// `_sample_goal`'s two transforms: an additive floor, then softmax 288 /// flattening at this temperature (digest: floor 0.05, T 2.0). 289 pub floor: f64, 290 pub temperature: f64, 291 /// Stop asking once this run has spent this much (the ledger's lifetime 292 /// cap still applies on top); `None` for no run limit. 293 pub run_dollars: Option<f64>, 294} 295 296impl Default for Config { 297 fn default() -> Self { 298 Self { margin: 512, reask_frames: 1800, floor: 0.05, temperature: 2.0, run_dollars: None } 299 } 300} 301 302impl Config { 303 /// The part of this config the shared engine ([`crate::Config`]) cares 304 /// about - everything except `margin`, which is this decision's own and 305 /// goes straight to the C shim instead. 306 fn sampling(self) -> crate::Config { 307 crate::Config { 308 reask_frames: self.reask_frames, 309 floor: self.floor, 310 temperature: self.temperature, 311 run_dollars: self.run_dollars, 312 } 313 } 314} 315 316/// Everything one goal choice needs: the offered goals, collapsed into 317/// distinct sentences ([`words::offers`]), and the little the question's 318/// wording needs about where Link is - built once, in [`Facts::new`], from 319/// nothing but the bot's own records. The game-state reading lives here and 320/// nowhere else. 321pub struct Facts { 322 indoors: bool, 323 place: String, 324 /// The parts of SRAM the question's state needs, as of the last 325 /// `Chooser::set_snapshot` before this choice - see [`Snapshot`]'s own 326 /// doc comment for why this module reads it this way rather than 327 /// reaching into the console itself. 328 snapshot: Snapshot, 329 offers: Vec<words::Offer>, 330 /// Every raw option's own identity (`kind|node|screen`), indexed exactly 331 /// as `offers[_].goals` indexes into it - what `decode`/`reuse` key a 332 /// remembered probability by, since a goal's identity is stable across 333 /// calls even when `here` has moved enough to change its sentence or 334 /// which offer it collapses into. 335 option_identities: Vec<String>, 336 /// Sorted copy of the same identities, for [`GoalChoice::key`] - two 337 /// calls with the same raw options are "the same question" even if 338 /// their offer grouping ends up differing slightly (`GoalChoice::reuse` 339 /// is what handles that). 340 identities: Vec<String>, 341} 342 343impl Facts { 344 pub fn new(here: &Situation, options: &[GoalOption], snapshot: Snapshot) -> Self { 345 let offers = words::offers(options, here); 346 let option_identities: Vec<String> = options.iter().map(identity).collect(); 347 let mut identities = option_identities.clone(); 348 identities.sort(); 349 let place = words::screen_name(&here.link_screen).unwrap_or_else(|| { 350 if here.link_indoors { "an unnamed room".to_owned() } else { "an unnamed part of the overworld".to_owned() } 351 }); 352 Self { indoors: here.link_indoors, place, snapshot, offers, option_identities, identities } 353 } 354 355 /// Each offer's sentence, with a vanilla dungeon's known reward 356 /// appended when the sentence names one Link has not entered a room 357 /// of yet ([`DUNGEON_LORE`]) - applied only when the bot's OWN words 358 /// already name the place (`words::screen_name`'s resolved names, e.g. 359 /// "Eastern Palace"), never invented for "somewhere never visited", 360 /// which is honestly all that is known about an unexplored door. 361 fn sentences(&self) -> Vec<String> { 362 self.offers 363 .iter() 364 .map(|o| { 365 DUNGEON_LORE.iter().find(|(name, _)| o.sentence.contains(name)).map_or_else( 366 || o.sentence.clone(), 367 |(name, lore)| format!("{} ({name} vanilla holds {lore}.)", o.sentence), 368 ) 369 }) 370 .collect() 371 } 372} 373 374/// Vanilla dungeon rewards and bosses - VANILLA, not the Randomizer's 375/// shuffled fill, since this bot plays a vanilla-preset cartridge 376/// (`research/zbanks-alttp.md`). Source: Zelda Dungeon Wiki, "A Link to the 377/// Past Dungeons" and "A Link to the Past Bosses" (zeldadungeon.net, 378/// retrieved 2026-09-22). Applied only to an option whose sentence already 379/// names one of these places - see [`Facts::sentences`]. 380const DUNGEON_LORE: [(&str, &str); 13] = [ 381 ("Hyrule Castle", "the sword and shield, and Zelda's cell"), 382 ("Eastern Palace", "the Pendant of Courage, guarded by Armos Knights"), 383 ("Desert Palace", "the Pendant of Power, guarded by Lanmolas"), 384 ("Tower of Hera", "the Pendant of Wisdom, guarded by Moldorm"), 385 ("Agahnim's Tower", "the way to the Dark World, guarded by Agahnim"), 386 ("Palace of Darkness", "a crystal, guarded by Helmasaur King"), 387 ("Swamp Palace", "a crystal, guarded by Arrghus"), 388 ("Skull Woods", "a crystal, guarded by Mothula"), 389 ("Thieves' Town", "a crystal, guarded by Blind the Thief"), 390 ("Ice Palace", "a crystal, guarded by Kholdstare"), 391 ("Misery Mire", "a crystal, guarded by Vitreous"), 392 ("Turtle Rock", "a crystal, guarded by Trinexx"), 393 ("Ganon's Tower", "Ganon himself, once all seven crystals are in"), 394]; 395 396/// One goal choice, for the panel and the log - everything a [`Record`] 397/// needs beyond `frame`/`kind`/`how`. 398#[derive(Clone, Debug, Serialize, Deserialize)] 399pub struct Answer { 400 /// One per option put to Jev (look-alike goals collapsed into one). 401 #[serde(rename = "options")] 402 pub sentences: Vec<String>, 403 /// For each option, the offered goals it stands for (indices into what 404 /// the bot offered; 0 is upstream's own pick). 405 pub goals: Vec<Vec<usize>>, 406 /// Jev's probability per option, in option order; `None` when not asked. 407 pub probabilities: Option<Vec<f64>>, 408 /// The option chosen, an index into `sentences`. 0 is upstream's pick. 409 pub picked: usize, 410 /// The goal handed back to the bot: `goals[picked][0]`. 411 pub goal: usize, 412} 413 414/// Marker type: the [`Decision`] this module implements. 415pub struct GoalChoice; 416 417impl Decision for GoalChoice { 418 type Facts = Facts; 419 type Answer = Answer; 420 /// Goal identity to Jev's probability for the option it was part of - 421 /// not the full `Answer`, because the NEXT call's offer grouping may 422 /// name the same goals with different sentences (`here` moved a little); 423 /// keeping it per goal identity is what lets `reuse` look each one up 424 /// under whatever grouping this call's `Facts` actually has. 425 type Memory = Vec<(String, f64)>; 426 type Key = Vec<String>; 427 type Asked = Key<ChoiceAnswer>; 428 429 const KIND: &'static str = "goal_choice"; 430 431 fn key(facts: &Facts) -> Vec<String> { 432 facts.identities.clone() 433 } 434 435 fn question(facts: &Facts) -> Option<Ask<Key<ChoiceAnswer>>> { 436 if facts.offers.len() < 2 { 437 return None; 438 } 439 let sentences = facts.sentences(); 440 let named: Vec<(String, Option<Json>)> = 441 IDS.iter().zip(&sentences).map(|(id, s)| ((*id).to_owned(), Some(Json::text(s)))).collect(); 442 // `named.len()` is `facts.offers.len()`, already checked >= 2 above 443 // and capped at `IDS.len()` == 6 by the C shim's own margin cap 444 // (this module's doc comment); the names themselves ("A".."F") are 445 // always distinct. So `Choice::new` can only refuse here if either 446 // invariant breaks, which is worth a loud failure, not a silent 447 // "nothing to ask". 448 let instructions = json!({ 449 "question": "Link is playing The Legend of Zelda: A Link to the Past, out to find every item and clear every dungeon. Which of these should he do next?", 450 "focus": "Weigh what each is likely to gain - an item, a new area, progress toward a dungeon - against how far away it is and whether it has already failed.", 451 }); 452 let choice = Choice::new(json_of(&instructions), named) 453 .expect("2..=6 distinctly-named offers always build a Choice"); 454 let s = &facts.snapshot; 455 let state = json!({ 456 "game": "The Legend of Zelda: A Link to the Past (open mode: Zelda is already rescued, the castle gate is open)", 457 "link_is": format!("{} {}", if facts.indoors { "inside, in" } else { "outdoors, in" }, facts.place), 458 "link_has": s.inventory(), 459 "hearts": s.hearts, 460 "heart_pieces_of_4": s.heart_pieces, 461 "pendants": s.pendant_names(), 462 "crystals": s.crystals.count_ones(), 463 "current_dungeon_small_keys": s.current_dungeon_small_keys, 464 "big_keys_held": s.big_key_names(), 465 // Not a count of decisions made (that resets to zero on every 466 // restart - the bug this replaces, user 2026-09-22): items, 467 // pendants, crystals, big keys and heart pieces actually held, 468 // read fresh from SRAM every time. 469 "progress_so_far": s.progress_count(), 470 }); 471 let mut questions = Questions::new(); 472 let next = questions.choice("next", choice).expect("one question under a fresh id"); 473 Some(Ask { state: json_of(&state), questions, keys: next }) 474 } 475 476 fn decode( 477 facts: &Facts, 478 response: &jev_protocol::Response, 479 next: &Key<ChoiceAnswer>, 480 sample: &mut dyn FnMut(&[f64]) -> usize, 481 ) -> Result<(Answer, Vec<(String, f64)>), &'static str> { 482 // Verified by jev-protocol: one probability per option asked, in the 483 // order asked, which is `IDS[..offers.len()]`. 484 let probabilities: Vec<f64> = response.get(*next).probabilities.iter().map(|(_, p)| *p).collect(); 485 if probabilities.iter().sum::<f64>() <= 0.0 { 486 return Err("every probability was zero"); 487 } 488 let memory: Vec<(String, f64)> = facts 489 .offers 490 .iter() 491 .zip(&probabilities) 492 .flat_map(|(o, p)| o.goals.iter().map(move |&g| (facts.option_identities[g].clone(), *p))) 493 .collect(); 494 let picked = sample(&probabilities); 495 Ok(( 496 Answer { 497 sentences: facts.sentences(), 498 goals: facts.offers.iter().map(|o| o.goals.clone()).collect(), 499 probabilities: Some(probabilities), 500 picked, 501 goal: facts.offers[picked].goals[0], 502 }, 503 memory, 504 )) 505 } 506 507 fn fallback(facts: &Facts) -> (Answer, String) { 508 let sentences = facts.sentences(); 509 let goals: Vec<Vec<usize>> = facts.offers.iter().map(|o| o.goals.clone()).collect(); 510 let goal = goals.first().map_or(0, |g| g[0]); 511 let why = if facts.offers.len() < 2 { 512 format!("the {} goals offered are one choice", facts.identities.len()) 513 } else { 514 "upstream's own pick".to_owned() 515 }; 516 (Answer { sentences, goals, probabilities: None, picked: 0, goal }, why) 517 } 518 519 fn reuse(facts: &Facts, memory: &Vec<(String, f64)>, sample: &mut dyn FnMut(&[f64]) -> usize) -> Answer { 520 let probabilities: Vec<f64> = facts 521 .offers 522 .iter() 523 .map(|o| { 524 let first = &facts.option_identities[o.goals[0]]; 525 memory.iter().find(|(id, _)| id == first).map_or(0.0, |(_, p)| *p) 526 }) 527 .collect(); 528 let picked = sample(&probabilities); 529 Answer { 530 sentences: facts.sentences(), 531 goals: facts.offers.iter().map(|o| o.goals.clone()).collect(), 532 picked, 533 goal: facts.offers[picked].goals[0], 534 probabilities: Some(probabilities), 535 } 536 } 537 538 fn summary(answer: &Answer) -> String { 539 format!("picked {} of {}", answer.picked, answer.sentences.len()) 540 } 541 542 /// Three earlier shapes of a goal-choice line, all real in 543 /// `run/zbanks-window/jev.jsonl` (incident 2026-09-22 - see 544 /// `Engine::load_history`'s doc comment): a line has no `goals` field at 545 /// all (the earliest lines, before per-goal indices were logged); a 546 /// `how.asked` object with only `dollars`/`millis` (before 547 /// `input_tokens`/`prompt` were added); and `how.reused`/`how.one_choice` 548 /// as a bare number (`{"reused": 43771}`, `{"one_choice": 2}`) rather 549 /// than today's struct shape (`{"reused": {"frame": 43771}}`, 550 /// `{"no_question": {"why": "..."}}` - `one_choice` was also renamed 551 /// when it became [`How::NoQuestion`]). Every one of these lines is 552 /// otherwise a perfectly good goal choice, so all three are rewritten 553 /// here rather than left for `load_history` to discard. 554 fn migrate(mut value: serde_json::Value) -> serde_json::Value { 555 let Some(obj) = value.as_object_mut() else { return value }; 556 let option_count = obj.get("options").and_then(|o| o.as_array()).map_or(0, Vec::len); 557 if !obj.contains_key("goals") { 558 let goals: Vec<serde_json::Value> = (0..option_count).map(|i| serde_json::json!([i])).collect(); 559 obj.insert("goals".to_owned(), serde_json::Value::Array(goals)); 560 } 561 if !obj.contains_key("goal") { 562 let picked = obj.get("picked").and_then(serde_json::Value::as_u64).unwrap_or(0) as usize; 563 let goal = obj 564 .get("goals") 565 .and_then(|g| g.get(picked)) 566 .and_then(|g| g.get(0)) 567 .cloned() 568 .unwrap_or(serde_json::json!(0)); 569 obj.insert("goal".to_owned(), goal); 570 } 571 if let Some(how) = obj.get_mut("how").and_then(|h| h.as_object_mut()) { 572 // `reused` exists in BOTH shapes (only the value's shape 573 // changed), so this must only touch it when it is the OLD bare 574 // number - `remove` unconditionally, the way `one_choice` below 575 // does, would drop today's `{"frame": N}` shape on the floor 576 // (found by `an_old_line_with_no_kind_field_defaults_to_goal_choice`, 577 // which uses today's shape and has no `kind` field either). 578 if how.get("reused").is_some_and(serde_json::Value::is_number) { 579 let frame = how.remove("reused").expect("just checked present"); 580 how.insert("reused".to_owned(), serde_json::json!({ "frame": frame })); 581 } 582 if let Some(goals) = how.remove("one_choice") { 583 let goals = goals.get("goals").cloned().unwrap_or(goals); 584 let why = format!("the {goals} goals offered are one choice"); 585 how.insert("no_question".to_owned(), serde_json::json!({ "why": why })); 586 } 587 if let Some(asked) = how.get_mut("asked").and_then(|a| a.as_object_mut()) { 588 asked.entry("input_tokens").or_insert(serde_json::json!(0)); 589 asked.entry("prompt").or_insert(serde_json::Value::Null); 590 } 591 } 592 value 593 } 594} 595 596/// One [`Engine<GoalChoice>`] plus this decision's own extra bookkeeping: 597/// how many choices differed from upstream's own pick. That count depends on 598/// knowing `Answer::goal == 0` means "took upstream's pick" - a fact only 599/// this module has, so it is not part of the shared [`crate::Totals`]. 600pub struct Chooser { 601 pub engine: Engine<GoalChoice>, 602 pub overrides: u32, 603 /// The most recently read [`Snapshot`], refreshed by [`Self::set_snapshot`] 604 /// - `Default` (all zero/false) until the first frame does, which is 605 /// harmless: a choice that fires before any snapshot has been read 606 /// would otherwise not exist to make. 607 snapshot: Snapshot, 608} 609 610impl Chooser { 611 pub fn new(jev: jev_http::Jev, config: Config) -> Result<Self, String> { 612 Ok(Self { engine: Engine::new(jev, config.sampling())?, overrides: 0, snapshot: Snapshot::default() }) 613 } 614 615 pub fn guards(&mut self) -> Result<jev_http::Status, String> { 616 self.engine.guards() 617 } 618 619 pub fn log_to(&mut self, path: &Path) -> Result<(), String> { 620 self.engine.log_to(path) 621 } 622 623 pub fn load_history(&mut self, path: &Path) -> Result<usize, String> { 624 self.engine.load_history(path) 625 } 626 627 pub fn history(&self) -> impl DoubleEndedIterator<Item = &Record<Answer>> { 628 self.engine.history() 629 } 630 631 /// Refresh the SRAM/WRAM facts a choice's wording needs. Call this once 632 /// a frame, BEFORE `zbanks::Bot::tick`: the C shim's goal-choice hook 633 /// can fire synchronously inside that call, and whatever was read last 634 /// is what a choice made this frame will see. 635 pub fn set_snapshot(&mut self, snapshot: Snapshot) { 636 self.snapshot = snapshot; 637 } 638 639 fn choose(&mut self, frame: u32, here: &Situation, options: &[GoalOption]) -> usize { 640 let facts = Facts::new(here, options, self.snapshot); 641 let record = self.engine.ask(frame, &facts); 642 if record.answer.goal != 0 { 643 self.overrides += 1; 644 } 645 record.answer.goal 646 } 647} 648 649/// A [`Chooser`] shared between the bot (which calls it) and whoever shows 650/// or measures it (the Bot panel, the headless summary). 651#[derive(Clone)] 652pub struct Shared(pub Rc<RefCell<Chooser>>); 653 654impl Shared { 655 pub fn new(chooser: Chooser) -> Self { 656 Self(Rc::new(RefCell::new(chooser))) 657 } 658} 659 660impl GoalChooser for Shared { 661 fn choose(&mut self, frame: u32, here: &Situation, options: &[GoalOption]) -> Option<usize> { 662 Some(self.0.borrow_mut().choose(frame, here, options)) 663 } 664} 665 666#[cfg(test)] 667mod tests { 668 use super::*; 669 670 fn here() -> Situation { 671 Situation { 672 link_screen: "Castle Yard 600,600 x 9ff,9ff".into(), 673 link_indoors: false, 674 has_sword: true, 675 link: (0x7f8, 0x832), 676 screen: ((0x600, 0x600), (0x9ff, 0x9ff)), 677 } 678 } 679 680 fn option(kind: &str, node: &str, at: (u16, u16), distance: i32) -> GoalOption { 681 GoalOption { 682 kind: kind.into(), 683 node: node.into(), 684 screen: "Castle Yard 600,600 x 9ff,9ff".into(), 685 screen_id: 0x0606, 686 indoors: false, 687 direction: 0, 688 sprite_type: 0, 689 sprite_subtype: 0, 690 item: String::new(), 691 needs: "[Any]".into(), 692 score: distance, 693 distance, 694 attempts: 0, 695 adjacent_known: false, 696 at, 697 screen_bounds: ((0x600, 0x600), (0x9ff, 0x9ff)), 698 via: String::new(), 699 via_direction: 0, 700 screens_on_path: 0, 701 on_way_to: 0, 702 } 703 } 704 705 /// Behaviour preservation: the exact request `zbanks-jev`'s `ask_jev` 706 /// built for this fixture, captured from the pre-refactor code 707 /// (`packages/zbanks-jev`, before it became this crate) via a temporary 708 /// test and saved verbatim below. `GoalChoice::question` must render the 709 /// identical JSON, byte for byte, for the same facts. `Facts` is built 710 /// directly (not through `Facts::new`/`words::offers`, which are 711 /// unchanged by this rewrite and have their own tests) so this isolates 712 /// exactly the piece that changed: how facts render into the request. 713 /// A representative mid-game snapshot: a sword, the boots and the moon 714 /// pearl, one pendant, two crystals, a key in hand and Eastern Palace's 715 /// big key - enough that every new state field in the request is 716 /// non-trivially populated, not left at its all-zero `Default`. 717 fn sample_snapshot() -> Snapshot { 718 Snapshot { 719 hearts: 14.0, 720 heart_pieces: 2, 721 sword: 1, 722 boots: true, 723 moon_pearl: true, 724 pendants: 0x01, 725 crystals: 0x03, 726 current_dungeon_small_keys: 1, 727 big_keys: 0x2000, // Eastern Palace, DUNGEON_BITS 728 ..Snapshot::default() 729 } 730 } 731 732 /// Behaviour preservation, UPDATED deliberately (user, 2026-09-22, 733 /// "does it look like jev is being given enough information to make a 734 /// correct decision?" - it did not: a real frame-9811 request carried 735 /// only `game`/`goals_already_done`/`link_has_a_sword`/`link_is`). 736 /// `state` now also carries inventory, hearts, pendants, crystals, the 737 /// current dungeon's small keys, and which dungeons' big key Link 738 /// holds, and `goals_already_done` (a decision counter that reset to 739 /// zero on every restart) is replaced by `progress_so_far`, derived 740 /// fresh from SRAM every time. Before/after for this exact fixture: 741 /// 742 /// BEFORE: `{"game": "...", "goals_already_done": 7, 743 /// "link_has_a_sword": true, "link_is": "outdoors, in Castle Yard"}` 744 /// 745 /// AFTER: see `golden` below. 746 #[test] 747 fn the_request_sent_to_jev_is_unchanged_by_the_rewrite() { 748 let facts = Facts { 749 indoors: false, 750 place: "Castle Yard".to_owned(), 751 snapshot: sample_snapshot(), 752 offers: vec![ 753 words::Offer { 754 sentence: "Lift the pot in the north side of this screen, 6 steps west and 2 steps north of Link. It is about 62 steps away.".to_owned(), 755 goals: vec![0], 756 }, 757 words::Offer { 758 sentence: "Open the unopened chest in the north-east corner of this screen, right beside Link. It is about 3 steps away.".to_owned(), 759 goals: vec![1], 760 }, 761 ], 762 option_identities: vec!["PICKUP|pot|Castle Yard".to_owned(), "CHEST|chest 0|Castle Yard".to_owned()], 763 identities: vec!["CHEST|chest 0|Castle Yard".to_owned(), "PICKUP|pot|Castle Yard".to_owned()], 764 }; 765 let ask = GoalChoice::question(&facts).expect("two distinct offers"); 766 let rendered = sent(&ask); 767 let golden: serde_json::Value = serde_json::from_str( 768 r#"{ 769 "model": "jev-1.13.0", 770 "questions": { 771 "next": { 772 "criteria": { 773 "A": "Lift the pot in the north side of this screen, 6 steps west and 2 steps north of Link. It is about 62 steps away.", 774 "B": "Open the unopened chest in the north-east corner of this screen, right beside Link. It is about 3 steps away." 775 }, 776 "instructions": { 777 "focus": "Weigh what each is likely to gain - an item, a new area, progress toward a dungeon - against how far away it is and whether it has already failed.", 778 "question": "Link is playing The Legend of Zelda: A Link to the Past, out to find every item and clear every dungeon. Which of these should he do next?" 779 }, 780 "type": "choice" 781 } 782 }, 783 "state": { 784 "big_keys_held": ["Eastern Palace"], 785 "crystals": 2, 786 "current_dungeon_small_keys": 1, 787 "game": "The Legend of Zelda: A Link to the Past (open mode: Zelda is already rescued, the castle gate is open)", 788 "heart_pieces_of_4": 2, 789 "hearts": 14.0, 790 "link_has": ["a sword", "the Pegasus Boots", "the Moon Pearl"], 791 "link_is": "outdoors, in Castle Yard", 792 "pendants": ["Wisdom"], 793 "progress_so_far": 9 794 } 795 }"#, 796 ) 797 .expect("parsing the captured golden"); 798 assert_eq!(rendered, golden); 799 } 800 801 /// Vanilla dungeon knowledge is appended only when the bot's own words 802 /// already name the place - never invented for a door to somewhere 803 /// unexplored. 804 #[test] 805 fn a_named_dungeon_screen_gets_its_vanilla_reward_appended() { 806 let chest = option("CHEST", "chest 0", (0x640, 0x640), 500); 807 let mut ep_chest = option("CHEST", "chest 1", (0x5300, 0xe80), 3000); 808 ep_chest.screen = "EP 5200,e00 x 53ff,eff".into(); 809 ep_chest.screen_bounds = ((0x5200, 0xe00), (0x53ff, 0xeff)); 810 ep_chest.indoors = true; 811 let facts = Facts::new(&here(), &[chest.clone(), ep_chest], Snapshot::default()); 812 let sentences = facts.sentences(); 813 assert!(!sentences[0].contains("vanilla holds"), "no lore on Link's own screen: {}", sentences[0]); 814 assert!( 815 sentences[1].contains("Eastern Palace vanilla holds the Pendant of Courage"), 816 "{}", 817 sentences[1] 818 ); 819 // An unexplored door - "somewhere never visited" - names no place 820 // at all, so no lore can honestly attach to it. 821 let mut unexplored = option("EXPLORE", "door U 0x84", (0x800, 0x610), 700); 822 unexplored.direction = 1; 823 unexplored.screen = "0x0c50 v 0 (1".into(); // screen_name resolves this to None 824 let facts = Facts::new(&here(), &[chest, unexplored], Snapshot::default()); 825 let sentences = facts.sentences(); 826 assert!(!sentences[1].contains("vanilla holds"), "{}", sentences[1]); 827 } 828 829 #[test] 830 fn fewer_than_two_offers_asks_nothing() { 831 let pot_a = option("PICKUP", "pot a", (0x7e0, 0x6f0), 990); 832 let pot_b = option("PICKUP", "pot b", (0x7f0, 0x6f0), 1000); 833 let facts = Facts::new(&here(), &[pot_a, pot_b], Snapshot::default()); 834 assert!(GoalChoice::question(&facts).is_none(), "the two pots collapse to one offer"); 835 let (answer, why) = GoalChoice::fallback(&facts); 836 assert_eq!(answer.picked, 0); 837 assert!(why.contains("one choice"), "{why}"); 838 } 839 840 /// The `goals_already_done` decision-counter bug this replaced (user, 841 /// 2026-09-22): progress is read from SRAM, so it survives a restart 842 /// unchanged, unlike a counter that starts back at zero. 843 #[test] 844 fn progress_so_far_does_not_reset_on_restart() { 845 let snapshot = sample_snapshot(); 846 assert_eq!(snapshot.progress_count(), 9, "3 items + 1 pendant + 2 crystals + 1 big key + 2 heart pieces"); 847 // A brand new `Chooser` (what a restart builds) sees the same 848 // snapshot fresh from SRAM, not a counter reset to zero. 849 let facts_after_restart = Facts::new(&here(), &[option("CHEST", "chest 0", (0x900, 0x640), 48), option("CHEST", "chest 1", (0x100, 0x640), 48)], snapshot); 850 let ask = GoalChoice::question(&facts_after_restart).expect("two distinct offers"); 851 let rendered = sent(&ask); 852 assert_eq!(rendered["state"]["progress_so_far"], serde_json::json!(9)); 853 } 854 855 /// The request exactly as jev-protocol sends it, as a JSON value. 856 fn sent(ask: &crate::Ask<Key<ChoiceAnswer>>) -> serde_json::Value { 857 let bytes = jev_protocol::request_bytes(&crate::model(), &ask.state, &ask.questions).expect("a valid request"); 858 serde_json::from_slice(&bytes).expect("the request is JSON") 859 } 860 861 fn sandbox_chooser() -> Chooser { 862 let dir = std::env::temp_dir() 863 .join(format!("decisions-goal-choice-test-{}", std::process::id())) 864 .join(jev_http::ledger::now().to_string()); 865 let ledger = jev_http::Ledger::at(dir).expect("a sandbox ledger"); 866 let jev = jev_http::Jev::new("", "test", ledger, crate::model(), jev_http::Endpoint::api()) 867 .expect("a client with no key still builds"); 868 Chooser::new(jev, Config::default()).expect("building the tokio runtime") 869 } 870 871 fn scratch_log_path() -> std::path::PathBuf { 872 static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); 873 std::env::temp_dir().join(format!( 874 "decisions-goal-choice-history-test-{}-{}.jsonl", 875 std::process::id(), 876 NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) 877 )) 878 } 879 880 fn one_choice_facts() -> Facts { 881 let pot_a = option("PICKUP", "pot a", (0x7e0, 0x6f0), 990); 882 let pot_b = option("PICKUP", "pot b", (0x7f0, 0x6f0), 1000); 883 Facts::new(&here(), &[pot_a, pot_b], Snapshot::default()) 884 } 885 886 #[test] 887 fn a_restart_reloads_the_history_a_previous_window_wrote() { 888 let path = scratch_log_path(); 889 let mut first = sandbox_chooser(); 890 first.log_to(&path).expect("opening the log"); 891 for frame in [10, 20, 30] { 892 first.engine.ask(frame, &one_choice_facts()); 893 } 894 assert_eq!(first.history().count(), 3); 895 896 let mut second = sandbox_chooser(); 897 let loaded = second.load_history(&path).expect("loading the previous window's history"); 898 assert_eq!(loaded, 3); 899 let frames: Vec<u32> = second.history().map(|d| d.frame).collect(); 900 assert_eq!(frames, vec![10, 20, 30], "oldest first, same order as it was written"); 901 902 let _ = std::fs::remove_file(&path); 903 } 904 905 #[test] 906 fn an_unreadable_line_is_skipped_not_a_read_failure() { 907 let path = scratch_log_path(); 908 std::fs::write(&path, "{\"frame\": 1, \"this is not an Answer\": true}\nnot json at all\n\n") 909 .expect("writing a scratch log with garbage lines"); 910 let mut chooser = sandbox_chooser(); 911 let loaded = chooser.load_history(&path).expect("a torn or unparseable line must not fail the whole read"); 912 assert_eq!(loaded, 0); 913 914 let _ = std::fs::remove_file(&path); 915 } 916 917 #[test] 918 fn no_log_yet_is_not_an_error() { 919 let path = scratch_log_path(); 920 let mut chooser = sandbox_chooser(); 921 assert_eq!(chooser.load_history(&path).expect("a first window has no history to load"), 0); 922 } 923 924 /// An old log line, written before `kind` existed, is still read as a 925 /// `goal_choice` record. 926 #[test] 927 fn an_old_line_with_no_kind_field_defaults_to_goal_choice() { 928 let path = scratch_log_path(); 929 std::fs::write( 930 &path, 931 "{\"frame\": 5, \"options\": [\"a\", \"b\"], \"goals\": [[0], [1]], \"probabilities\": [0.6, 0.4], \"picked\": 0, \"goal\": 0, \"how\": {\"reused\": {\"frame\": 1}}}\n", 932 ) 933 .expect("writing an old-shape scratch log line"); 934 let mut chooser = sandbox_chooser(); 935 let loaded = chooser.load_history(&path).expect("an old line with no `kind` still parses"); 936 assert_eq!(loaded, 1); 937 assert_eq!(chooser.history().next().map(|r| r.kind.as_str()), Some("goal_choice")); 938 939 let _ = std::fs::remove_file(&path); 940 } 941 942 /// Real lines copied from `run/zbanks-window/jev.jsonl` (incident 943 /// 2026-09-22: 1,659 lines there, almost the whole log, silently read 944 /// as "no choices yet" because every one of them predates `goals`, the 945 /// richer `how.asked`, or the struct shape of `how.reused`/`how.one_choice`). 946 /// `Engine::load_history`'s `Decision::migrate` hook must read all three. 947 #[test] 948 fn real_lines_from_every_earlier_shape_all_still_load() { 949 let path = scratch_log_path(); 950 std::fs::write( 951 &path, 952 concat!( 953 // The earliest shape: no `goals`, no `goal`, `how.asked` has 954 // only `dollars`/`millis`. 955 r#"{"frame":0,"how":{"asked":{"dollars":0.000023562,"millis":333}},"options":["Lift the pot right here in Link's House to see what is under it. It is about 26 steps away.","Open the chest right here in Link's House. It is about 28 steps away.","Lift the pot right here in Link's House to see what is under it. It is about 40 steps away.","Lift the pot right here in Link's House to see what is under it. It is about 54 steps away."],"picked":0,"probabilities":[0.76,0.23,0.01,0.0]}"#, 956 "\n", 957 // `how.reused` as a bare frame number, not `{"frame": N}`. 958 r#"{"frame":44054,"goal":0,"goals":[[0],[1]],"how":{"reused":43771},"options":["Open the chest right here in Blind's Basement. It is about 5 steps away, and was already tried 2 times and failed.","Go through the north door right here in Blind's Basement, to somewhere never visited. It is about 44 steps away."],"picked":0,"probabilities":[0.87,0.13]}"#, 959 "\n", 960 // `how.one_choice` as a bare goal count, not `how.no_question`. 961 r#"{"frame":124,"goal":0,"goals":[[0,1]],"how":{"one_choice":2},"options":["Lift the pot in the west side of this screen to see what is under it, right beside Link. It is about 2 steps away."],"picked":0,"probabilities":null}"#, 962 "\n", 963 ), 964 ) 965 .expect("writing real historical lines"); 966 let mut chooser = sandbox_chooser(); 967 let loaded = chooser.load_history(&path).expect("every earlier shape must still parse"); 968 assert_eq!(loaded, 3, "all three real lines load, none silently skipped"); 969 970 let records: Vec<_> = chooser.history().collect(); 971 assert_eq!(records[0].answer.goals, vec![vec![0], vec![1], vec![2], vec![3]], "goals derived from options when absent"); 972 assert_eq!(records[0].answer.goal, 0); 973 assert!(matches!(&records[0].how, crate::How::Asked { input_tokens: 0, prompt, .. } if prompt.is_null())); 974 975 assert!(matches!(&records[1].how, crate::How::Reused { frame: 43771 })); 976 977 assert!(matches!(&records[2].how, crate::How::NoQuestion { why } if why == "the 2 goals offered are one choice")); 978 979 let _ = std::fs::remove_file(&path); 980 } 981}