jevsnes.git / packages / decisions / src / goal_choice.rs

Jev at the zbanks bot's goal choice - the first [crate::Decision] this project built, and the one this module implements.

The C bot picks its next goal as the one with the lowest score (ap_goal_evaluate, ap_plan.c). Carried patch 0002 lets a host make that choice instead, and zbanks::Bot::set_goal_chooser offers it the satisfiable goals scored within a margin of the lowest (at most six, upstream's pick first - IDS below relies on that cap). [Shared] is the chooser that asks Jev: the options said as sentences ([words]), one Choice question, and a pick sampled from Jev's distribution the way the digest's _sample_goal does it (research/jev-api-digest.md, "How it samples").

Goals nobody could tell apart from the words are one option ([words::offers]); when that leaves fewer than two, [GoalChoice]'s question returns None and the engine falls back without asking.

let client = jev_http::Jev::from_env("native", decisions::model()).expect("key")?;
let mut chooser = decisions::goal_choice::Chooser::new(client, decisions::goal_choice::Config::default())?;
chooser.load_history(&log_path)?; // before log_to, so a restart keeps the panel's history
chooser.log_to(&log_path)?;
let chooser = decisions::goal_choice::Shared::new(chooser);
bot.set_goal_chooser(Some(Box::new(chooser.clone())), decisions::goal_choice::Config::default().margin);
// chooser.0.borrow().engine.last / .engine.totals / .engine.history() / .overrides:
// the last decision, running totals, the bounded newest-last history the
// Jev panel and the `jev_history` MCP tool read, and how many choices
// differed from upstream's own pick.
31pub mod words;
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};

A JSON value as jev-protocol's Json: serde_json's own output, so always JSON.

46fn json_of(value: &serde_json::Value) -> Json {
47    Json::verbatim(&value.to_string()).expect("serde_json writes JSON")
48}
50fn identity(option: &GoalOption) -> String {
51    format!("{}|{}|{}", option.kind, option.node, option.screen)
52}

The option names sent to Jev. Fixed at six because the C shim never offers more (set_goal_chooser's own margin cap) - question relies on this to guarantee Choice::new never fails for having duplicate names.

57const IDS: [&str; 6] = ["A", "B", "C", "D", "E", "F"];

Every dungeon/area bit link_compass/link_bigkey/link_dungeon_map agree on (research/alttp-ram-map.md, "$7EF364"/"$7EF366": SET 1 is the low byte, SET 2 the high byte of the 16-bit field this reads as one 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];

The parts of SRAM/WRAM a goal choice's wording needs beyond what the bot's own goal records say - inventory, hearts, pendants, crystals, and dungeon keys. This module never reaches into packages/zbanks or the console itself to get it (that would mean touching the GoalChooser trait's signature, which lives there, not here): instead [Chooser::set_snapshot] is refreshed once a frame by whoever already reads work RAM for another reason (apps/native's tick, before Bot::tick, since a goal choice can fire synchronously inside it, and apps/zbanks's headless loop the same way).

Kept as named, derived values rather than raw WRAM bytes, so the address knowledge (research/alttp-ram-map.md, "$7EF340" onward) stays in this 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,

$7EF374, bits 0x01=Wisdom(red), 0x02=Power(blue), 0x04=Courage(green) - jpdasm symbols_sram.asm:779-781.

122    pub pendants: u8,

$7EF37A, one bit per dungeon (research/alttp-ram-map.md, "$7EF37A"); only the COUNT is used today, since Jev is never asked to plan a specific crystal dungeon yet.

126    pub crystals: u8,

$7EF36F: the CURRENT dungeon's small-key count only - there is no confirmed per-dungeon small-key array to read (the doc's own link_keys_earned_per_dungeon[] entry is explicitly "not confirmed beyond base"), so this is honestly partial rather than a guessed full breakdown.

132    pub current_dungeon_small_keys: u8,

$7EF366/367, [DUNGEON_BITS]'s layout: which dungeons' big key Link holds.

135    pub big_keys: u16,
136}
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    }

Everything Link is holding, as short phrases - only what he actually has, so this grows with the run instead of listing every possible 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    }
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    }

How much of the game's progress this save shows: NOT decisions made (which reset to zero on every restart - the bug this replaces, user 2026-09-22, "goals_already_done: 0" on a run well past the start), but items, pendants, crystals, big keys and heart pieces actually held, read fresh from SRAM every time and therefore correct across 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}

How the chooser behaves. The defaults are what was verified.

277#[derive(Clone, Copy, Debug)]
278pub struct Config {

How far above upstream's lowest score a goal may be and still be offered, in the bot's score units (path cost, roughly pixels walked). Passed straight to zbanks::Bot::set_goal_chooser; the engine below never reads it.

283    pub margin: i32,

A set of options asked about within this many frames is not asked again; the last answer for it is resampled instead.

286    pub reask_frames: u32,

_sample_goal's two transforms: an additive floor, then softmax flattening at this temperature (digest: floor 0.05, T 2.0).

289    pub floor: f64,
290    pub temperature: f64,

Stop asking once this run has spent this much (the ledger's lifetime cap still applies on top); None for no run limit.

293    pub run_dollars: Option<f64>,
294}
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 {

The part of this config the shared engine ([crate::Config]) cares about - everything except margin, which is this decision's own and 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}

Everything one goal choice needs: the offered goals, collapsed into distinct sentences ([words::offers]), and the little the question's wording needs about where Link is - built once, in [Facts::new], from nothing but the bot's own records. The game-state reading lives here and nowhere else.

321pub struct Facts {
322    indoors: bool,
323    place: String,

The parts of SRAM the question's state needs, as of the last Chooser::set_snapshot before this choice - see [Snapshot]'s own doc comment for why this module reads it this way rather than reaching into the console itself.

328    snapshot: Snapshot,
329    offers: Vec<words::Offer>,

Every raw option's own identity (kind|node|screen), indexed exactly as offers[_].goals indexes into it - what decode/reuse key a remembered probability by, since a goal's identity is stable across calls even when here has moved enough to change its sentence or which offer it collapses into.

335    option_identities: Vec<String>,

Sorted copy of the same identities, for [GoalChoice::key] - two calls with the same raw options are "the same question" even if their offer grouping ends up differing slightly (GoalChoice::reuse is what handles that).

340    identities: Vec<String>,
341}
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    }

Each offer's sentence, with a vanilla dungeon's known reward appended when the sentence names one Link has not entered a room of yet ([DUNGEON_LORE]) - applied only when the bot's OWN words already name the place (words::screen_name's resolved names, e.g. "Eastern Palace"), never invented for "somewhere never visited", 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}

Vanilla dungeon rewards and bosses - VANILLA, not the Randomizer's shuffled fill, since this bot plays a vanilla-preset cartridge (research/zbanks-alttp.md). Source: Zelda Dungeon Wiki, "A Link to the Past Dungeons" and "A Link to the Past Bosses" (zeldadungeon.net, retrieved 2026-09-22). Applied only to an option whose sentence already 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];

One goal choice, for the panel and the log - everything a [Record] needs beyond frame/kind/how.

398#[derive(Clone, Debug, Serialize, Deserialize)]
399pub struct Answer {

One per option put to Jev (look-alike goals collapsed into one).

401    #[serde(rename = "options")]
402    pub sentences: Vec<String>,

For each option, the offered goals it stands for (indices into what the bot offered; 0 is upstream's own pick).

405    pub goals: Vec<Vec<usize>>,

Jev's probability per option, in option order; None when not asked.

407    pub probabilities: Option<Vec<f64>>,

The option chosen, an index into sentences. 0 is upstream's pick.

409    pub picked: usize,

The goal handed back to the bot: goals[picked][0].

411    pub goal: usize,
412}

Marker type: the [Decision] this module implements.

415pub struct GoalChoice;
417impl Decision for GoalChoice {
418    type Facts = Facts;
419    type Answer = Answer;

Goal identity to Jev's probability for the option it was part of - not the full Answer, because the NEXT call's offer grouping may name the same goals with different sentences (here moved a little); keeping it per goal identity is what lets reuse look each one up 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>;
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    }

Three earlier shapes of a goal-choice line, all real in run/zbanks-window/jev.jsonl (incident 2026-09-22 - see Engine::load_history's doc comment): a line has no goals field at all (the earliest lines, before per-goal indices were logged); a how.asked object with only dollars/millis (before input_tokens/prompt were added); and how.reused/how.one_choice as a bare number ({"reused": 43771}, {"one_choice": 2}) rather than today's struct shape ({"reused": {"frame": 43771}}, {"no_question": {"why": "..."}} - one_choice was also renamed when it became [How::NoQuestion]). Every one of these lines is otherwise a perfectly good goal choice, so all three are rewritten 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}

One [Engine<GoalChoice>] plus this decision's own extra bookkeeping: how many choices differed from upstream's own pick. That count depends on knowing Answer::goal == 0 means "took upstream's pick" - a fact only 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,

The most recently read [Snapshot], refreshed by [Self::set_snapshot]

  • Default (all zero/false) until the first frame does, which is harmless: a choice that fires before any snapshot has been read would otherwise not exist to make.
607    snapshot: Snapshot,
608}
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    }

Refresh the SRAM/WRAM facts a choice's wording needs. Call this once a frame, BEFORE zbanks::Bot::tick: the C shim's goal-choice hook can fire synchronously inside that call, and whatever was read last is what a choice made this frame will see.

635    pub fn set_snapshot(&mut self, snapshot: Snapshot) {
636        self.snapshot = snapshot;
637    }
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}

A [Chooser] shared between the bot (which calls it) and whoever shows or measures it (the Bot panel, the headless summary).

651#[derive(Clone)]
652pub struct Shared(pub Rc<RefCell<Chooser>>);
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    }

Behaviour preservation: the exact request zbanks-jev's ask_jev built for this fixture, captured from the pre-refactor code (packages/zbanks-jev, before it became this crate) via a temporary test and saved verbatim below. GoalChoice::question must render the identical JSON, byte for byte, for the same facts. Facts is built directly (not through Facts::new/words::offers, which are unchanged by this rewrite and have their own tests) so this isolates exactly the piece that changed: how facts render into the request. A representative mid-game snapshot: a sword, the boots and the moon pearl, one pendant, two crystals, a key in hand and Eastern Palace's big key - enough that every new state field in the request is 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    }

Behaviour preservation, UPDATED deliberately (user, 2026-09-22, "does it look like jev is being given enough information to make a correct decision?" - it did not: a real frame-9811 request carried only game/goals_already_done/link_has_a_sword/link_is). state now also carries inventory, hearts, pendants, crystals, the current dungeon's small keys, and which dungeons' big key Link holds, and goals_already_done (a decision counter that reset to zero on every restart) is replaced by progress_so_far, derived fresh from SRAM every time. Before/after for this exact fixture:

BEFORE: {"game": "...", "goals_already_done": 7, "link_has_a_sword": true, "link_is": "outdoors, in Castle Yard"}

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    }

Vanilla dungeon knowledge is appended only when the bot's own words already name the place - never invented for a door to somewhere 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    }
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    }

The goals_already_done decision-counter bug this replaced (user, 2026-09-22): progress is read from SRAM, so it survives a restart 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    }

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    }
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    }

An old log line, written before kind existed, is still read as a 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    }

Real lines copied from run/zbanks-window/jev.jsonl (incident 2026-09-22: 1,659 lines there, almost the whole log, silently read as "no choices yet" because every one of them predates goals, the richer how.asked, or the struct shape of how.reused/how.one_choice). 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}