jevsnes.git / packages / decisions / src / goal_choice.rs
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}