Skip to main content

encounter_accept/
encounter_accept.rs

1//! Battle v2-d acceptance harness: boots a scaffolded zero-Rust project
2//! headless and drives a trainer battle (Run blocked, money on a win), a
3//! wild battle (Run succeeds — the `"run"` outcome), and a shop sell,
4//! printing the battle logs, money and inventory as evidence.
5//!
6//! Usage:
7//!
8//! ```sh
9//! cargo run -p dotzuki-runner --example encounter_accept -- <project-dir> [shot-dir]
10//! ```
11//!
12//! The project's entry scene should run `startBattle("bug-catcher")`, then
13//! `startBattle("slime")`, then `openShop(["potion"])` (the acceptance
14//! patches the jrpg template's StartTown `script.scene` accordingly). Battle
15//! 1 agenda: **Run** (blocked — turn not consumed), **Fight**. Battle 2
16//! agenda: **Run** (success). Shop agenda: **Sell** one Potion. Screenshots
17//! of the blocked-Run / got-away / sold lines land in `shot-dir` (default:
18//! no screenshots).
19
20use std::path::{Path, PathBuf};
21
22use dotzuki_engine::render::{FrameBuffer, Rgba};
23use dotzuki_engine::render_config::RenderConfig;
24use dotzuki_renderer::input::{GbButton, InputState};
25use dotzuki_runner::headless::save_png;
26use dotzuki_runner::{LoadedProject, RunnerGame, RunnerOptions, SCREEN_H, SCREEN_W};
27
28/// One root-menu pick for the scripted agenda.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30enum Pick {
31    Fight,
32    Run,
33}
34
35/// Draw the current frame into a PNG.
36fn snap(game: &mut RunnerGame, path: &Path) {
37    let mut fb = FrameBuffer::new(
38        RenderConfig::new(SCREEN_W as u32, SCREEN_H as u32),
39        Rgba::BLACK,
40    );
41    game.draw(&mut fb);
42    save_png(&fb, path).expect("screenshot");
43    println!("  [shot] {}", path.display());
44}
45
46fn main() {
47    let mut args = std::env::args().skip(1);
48    let dir = args
49        .next()
50        .expect("usage: encounter_accept <project-dir> [shot-dir]");
51    let shot_dir = args.next().map(PathBuf::from);
52
53    let project = LoadedProject::load(Path::new(&dir)).expect("load project");
54    let mut game = RunnerGame::new(
55        project,
56        RunnerOptions {
57            headless: true,
58            fresh: true,
59            // Deterministic battles: always hit, 89% variance, never crit.
60            rng_script: Some(vec![50, 100, 1]),
61            ..RunnerOptions::default()
62        },
63    )
64    .expect("boot game");
65    println!("money at boot: {}", game.money());
66
67    // The agenda per battle (index = battles started so far − 1): the
68    // trainer battle tries Run first (blocked), the wild battle runs.
69    // Battle 1's agenda is pre-armed: the entry scene suspends on
70    // `startBattle` during boot, so its first menu frame precedes the
71    // battle-start edge below (later battles arm on their edge — text plays
72    // between them).
73    let agendas: &[&[Pick]] = &[&[Pick::Run, Pick::Fight], &[Pick::Run]];
74    let mut battles_done = 0usize;
75    let mut agenda: Vec<Pick> = agendas[0].to_vec();
76    // Queued button presses; one key every other frame (a held key is not a
77    // fresh just-press, so keys alternate with idle frames).
78    let mut keys: Vec<GbButton> = Vec::new();
79    let mut last_log: Vec<String> = Vec::new();
80    let mut was_in_battle_prev = false;
81    let mut snapped: Vec<String> = Vec::new();
82    // Shop agenda: 0 = root (→ Sell), 1 = sell view (→ sell one), 2 = note
83    // (→ dismiss), 3 = back to root (→ B), 4 = exit (→ B), 5 = done.
84    let mut shop_step = 0usize;
85    let mut shop_done = false;
86
87    let mut input = InputState::new();
88    for frame in 0..6000u32 {
89        let was_in_battle = was_in_battle_prev;
90
91        // Decide this frame's input.
92        let mut mask = 0;
93        if frame % 2 == 0 {
94            if !keys.is_empty() {
95                mask = keys.remove(0).bit_mask();
96            } else if let Some(battle) = game.battle() {
97                if battle.in_menu() {
98                    let is_root = battle.menu_items().first().is_some_and(|i| i == "Fight");
99                    mask = if is_root {
100                        let pick = agenda.first().copied().unwrap_or(Pick::Fight);
101                        if !agenda.is_empty() {
102                            agenda.remove(0);
103                        }
104                        match pick {
105                            Pick::Fight => GbButton::A.bit_mask(),
106                            Pick::Run => {
107                                // Fight/Party/Item/Run → Down ×3, confirm.
108                                keys.push(GbButton::Down);
109                                keys.push(GbButton::Down);
110                                keys.push(GbButton::A);
111                                GbButton::Down.bit_mask()
112                            }
113                        }
114                    } else {
115                        GbButton::A.bit_mask() // skill submenu
116                    };
117                } else if frame % 4 == 0 {
118                    mask = GbButton::A.bit_mask(); // page narration
119                }
120            } else if game.shop_lines().is_some() && battles_done == 2 && !shop_done {
121                match shop_step {
122                    0 => {
123                        println!("shop root: {:?}", game.shop_lines().unwrap());
124                        keys.push(GbButton::Down); // → Sell
125                        keys.push(GbButton::A);
126                    }
127                    1 => {
128                        println!("sell view: {:?}", game.shop_lines().unwrap());
129                        keys.push(GbButton::A); // sell one Potion
130                    }
131                    2 => {
132                        let lines = game.shop_lines().unwrap();
133                        println!("sell note: {lines:?}");
134                        if let Some(dir) = &shot_dir {
135                            snap(&mut game, &dir.join("shop-sold.png"));
136                        }
137                        keys.push(GbButton::A); // dismiss the note
138                    }
139                    3 => keys.push(GbButton::B), // back to the root
140                    _ => keys.push(GbButton::B), // exit the shop
141                }
142                shop_step += 1;
143            } else if frame % 4 == 0 {
144                mask = GbButton::A.bit_mask(); // page dialogue
145            }
146        }
147        input.set_from_bitmask(mask);
148        game.update(&input);
149        input.begin_frame();
150
151        let in_battle = game.battle().is_some();
152        // Battle-start edge: print the matchup and load its agenda.
153        if in_battle && !was_in_battle {
154            let n = battles_done + 1;
155            let battle = game.battle().unwrap();
156            println!(
157                "battle {n} starts: {} vs {} (queued {}, trainer {}, reward {})",
158                battle.player().name,
159                battle.enemy().name,
160                battle.enemies_remaining(),
161                battle.is_trainer(),
162                battle.trainer_money(),
163            );
164            if battles_done > 0 {
165                agenda = agendas[battles_done].to_vec();
166            }
167        }
168        if in_battle {
169            // Screenshot the two v2-d narration lines on their first show.
170            // Screenshot the two v2-d narration lines on their first show.
171            let line = game
172                .battle()
173                .unwrap()
174                .current_line()
175                .map(str::to_string);
176            if let (Some(dir), Some(line)) = (&shot_dir, line) {
177                let tag = match line.as_str() {
178                    l if l.starts_with("Can't escape") => Some("run-blocked"),
179                    l if l.starts_with("Got away") => Some("run-safe"),
180                    l if l.starts_with("Got ") && l.ends_with("for winning!") => {
181                        Some("trainer-money")
182                    }
183                    _ => None,
184                };
185                if let Some(tag) = tag {
186                    if !snapped.contains(&tag.to_string()) {
187                        snapped.push(tag.to_string());
188                        snap(&mut game, &dir.join(format!("{tag}.png")));
189                    }
190                }
191            }
192            last_log = game.battle().unwrap().log().to_vec();
193        }
194        // Battle-end edge: the runner has harvested the state.
195        if was_in_battle && !in_battle {
196            battles_done += 1;
197            println!("\nbattle {battles_done} log:");
198            for line in &last_log {
199                println!("  {line}");
200            }
201            if battles_done == 1 {
202                assert!(
203                    last_log
204                        .iter()
205                        .any(|l| l == "Can't escape from a trainer battle!"),
206                    "the trainer Run attempt must be blocked (and the turn not consumed)"
207                );
208            }
209            println!("money after battle {battles_done}: {}", game.money());
210            if let Some(party) = game.party_state() {
211                for m in party {
212                    println!(
213                        "  {} hp {} mp {} level {} exp {}",
214                        m.id, m.hp, m.mp, m.level, m.exp
215                    );
216                }
217            }
218            println!();
219            last_log.clear();
220        }
221        // Shop-exit edge.
222        if shop_step >= 5 && game.shop_lines().is_none() && !shop_done {
223            shop_done = true;
224            println!("shop closed: money {}, inventory {:?}", game.money(), game.inventory());
225        }
226        // Everything done: both battles + the shop, scene finished.
227        if battles_done == 2 && shop_done && game.battle().is_none() {
228            println!("acceptance complete");
229            break;
230        }
231        was_in_battle_prev = in_battle;
232    }
233
234    // Verify the v2-d contract points and exit non-zero on any miss.
235    let money = game.money();
236    let potions = game.inventory().and_then(|inv| inv.get("potion").copied());
237    println!("final: money {money}, potions {potions:?}");
238    assert_eq!(battles_done, 2, "both battles must complete");
239    assert!(shop_done, "the shop sell must complete");
240    assert_eq!(money, 142, "100 start + 32 trainer + 10 sell (20/2)");
241    assert_eq!(potions, Some(2), "3 starting − 1 sold");
242}