Skip to main content

battle_accept/
battle_accept.rs

1//! Battle v2-b acceptance harness: boots a zero-Rust project headless and
2//! drives two consecutive battles with a scripted agenda, printing the
3//! battle logs, the persistent party state and the inventory as evidence.
4//!
5//! Usage:
6//!
7//! ```sh
8//! cargo run -p dotzuki-runner --example battle_accept -- <project-dir> [shot-dir]
9//! ```
10//!
11//! The project should trigger two `startBattle` commands in a row (the
12//! acceptance patches StartTown's `script.scene` accordingly). Battle 1
13//! agenda: **Item** (Potion at full HP — cap + decrement + turn consumed),
14//! **Party** (switch to the second member — the enemy hits the NEW member),
15//! **Fight**. Battle 2 agenda: **Fight** — it starts from the carried-over
16//! party state. Screenshots of the root/party/item menus land in `shot-dir`
17//! (default: no screenshots).
18
19use std::path::{Path, PathBuf};
20
21use dotzuki_engine::render::{FrameBuffer, Rgba};
22use dotzuki_engine::render_config::RenderConfig;
23use dotzuki_renderer::input::{GbButton, InputState};
24use dotzuki_runner::headless::save_png;
25use dotzuki_runner::{LoadedProject, RunnerGame, RunnerOptions, SCREEN_H, SCREEN_W};
26
27/// One root-menu pick for the scripted agenda.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29enum Pick {
30    Fight,
31    Party,
32    Item,
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.next().expect("usage: battle_accept <project-dir> [shot-dir]");
49    let shot_dir = args.next().map(PathBuf::from);
50
51    let project = LoadedProject::load(Path::new(&dir)).expect("load project");
52    let mut game = RunnerGame::new(
53        project,
54        RunnerOptions {
55            headless: true,
56            fresh: true,
57            // Deterministic battles: always hit, 89% variance, never crit.
58            rng_script: Some(vec![50, 100, 1]),
59            ..RunnerOptions::default()
60        },
61    )
62    .expect("boot game");
63
64    // The agenda per battle (index = battles started so far − 1).
65    let agendas: &[&[Pick]] = &[&[Pick::Item, Pick::Party, Pick::Fight], &[Pick::Fight]];
66    let mut battles_done = 0usize;
67    let mut agenda: Vec<Pick> = Vec::new();
68    // Queued button presses; one key every other frame (a held key is not a
69    // fresh just-press, so keys alternate with idle frames).
70    let mut keys: Vec<GbButton> = Vec::new();
71    let mut last_log: Vec<String> = Vec::new();
72    let mut snapped: Vec<String> = Vec::new();
73    let mut was_in_battle_prev = false;
74
75    let mut input = InputState::new();
76    for frame in 0..6000u32 {
77        let was_in_battle = was_in_battle_prev;
78
79        // Decide this frame's input.
80        let mut mask = 0;
81        if frame % 2 == 0 {
82            if !keys.is_empty() {
83                mask = keys.remove(0).bit_mask();
84            } else if let Some(battle) = game.battle() {
85                if battle.in_menu() {
86                    let items = battle.menu_items();
87                    let label = if items.first().is_some_and(|i| i == "Fight") {
88                        "root"
89                    } else if items.iter().any(|i| i.contains('/')) {
90                        "party"
91                    } else {
92                        "sub"
93                    };
94                    if let Some(dir) = &shot_dir {
95                        let tag = format!("battle{}-{label}", battles_done + 1);
96                        if !snapped.contains(&tag) {
97                            snapped.push(tag.clone());
98                            snap(&mut game, &dir.join(format!("{tag}.png")));
99                        }
100                    }
101                    mask = match label {
102                        "root" => {
103                            let pick = agenda.first().copied().unwrap_or(Pick::Fight);
104                            if !agenda.is_empty() {
105                                agenda.remove(0);
106                            }
107                            match pick {
108                                Pick::Fight => GbButton::A.bit_mask(),
109                                Pick::Party => {
110                                    keys.push(GbButton::A);
111                                    GbButton::Down.bit_mask()
112                                }
113                                Pick::Item => {
114                                    keys.push(GbButton::Down);
115                                    keys.push(GbButton::A);
116                                    GbButton::Down.bit_mask()
117                                }
118                            }
119                        }
120                        // Submenus: confirm the entry under the cursor (the
121                        // party cursor starts on the first switchable member).
122                        _ => GbButton::A.bit_mask(),
123                    };
124                } else if frame % 4 == 0 {
125                    mask = GbButton::A.bit_mask(); // page narration
126                }
127            } else if frame % 4 == 0 {
128                mask = GbButton::A.bit_mask(); // page dialogue
129            }
130        }
131        input.set_from_bitmask(mask);
132        game.update(&input);
133        input.begin_frame();
134
135        let in_battle = game.battle().is_some();
136        // Battle-start edge: print the party it starts with (the carried-over
137        // state from battle 2 on) and load its agenda.
138        if in_battle && !was_in_battle {
139            let n = battles_done + 1;
140            let battle = game.battle().unwrap();
141            println!("battle {n} starts:");
142            for c in battle.party() {
143                let status = c.status.as_deref().unwrap_or("-");
144                println!(
145                    "  {} {}/{} MP {}/{} status {}",
146                    c.name, c.hp, c.max_hp, c.mp, c.max_mp, status
147                );
148            }
149            println!("  inventory: {:?}", battle.inventory());
150            agenda = agendas[battles_done].to_vec();
151        }
152        if in_battle {
153            last_log = game.battle().unwrap().log().to_vec();
154        }
155        // Battle-end edge: the runner has harvested the party state.
156        if was_in_battle && !in_battle {
157            battles_done += 1;
158            println!("\nbattle {battles_done} log:");
159            for line in &last_log {
160                println!("  {line}");
161            }
162            if let Some(party) = game.party_state() {
163                println!("party state after battle {battles_done}:");
164                for m in party {
165                    println!("  {} hp {} mp {} status {:?}", m.id, m.hp, m.mp, m.status);
166                }
167            }
168            if let Some(inv) = game.inventory() {
169                println!("inventory: {inv:?}");
170            }
171            println!();
172            last_log.clear();
173            if battles_done == 2 {
174                println!("both battles done — stopping");
175                break;
176            }
177        }
178        was_in_battle_prev = in_battle;
179    }
180
181    if battles_done < 2 {
182        eprintln!("harness: only {battles_done} battle(s) completed within the frame cap");
183        std::process::exit(1);
184    }
185}