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
49        .next()
50        .expect("usage: battle_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
66    // The agenda per battle (index = battles started so far − 1).
67    let agendas: &[&[Pick]] = &[&[Pick::Item, Pick::Party, Pick::Fight], &[Pick::Fight]];
68    let mut battles_done = 0usize;
69    let mut agenda: Vec<Pick> = Vec::new();
70    // Queued button presses; one key every other frame (a held key is not a
71    // fresh just-press, so keys alternate with idle frames).
72    let mut keys: Vec<GbButton> = Vec::new();
73    let mut last_log: Vec<String> = Vec::new();
74    let mut snapped: Vec<String> = Vec::new();
75    let mut was_in_battle_prev = false;
76
77    let mut input = InputState::new();
78    for frame in 0..6000u32 {
79        let was_in_battle = was_in_battle_prev;
80
81        // Decide this frame's input.
82        let mut mask = 0;
83        if frame % 2 == 0 {
84            if !keys.is_empty() {
85                mask = keys.remove(0).bit_mask();
86            } else if let Some(battle) = game.battle() {
87                if battle.in_menu() {
88                    let items = battle.menu_items();
89                    let label = if items.first().is_some_and(|i| i == "Fight") {
90                        "root"
91                    } else if items.iter().any(|i| i.contains('/')) {
92                        "party"
93                    } else {
94                        "sub"
95                    };
96                    if let Some(dir) = &shot_dir {
97                        let tag = format!("battle{}-{label}", battles_done + 1);
98                        if !snapped.contains(&tag) {
99                            snapped.push(tag.clone());
100                            snap(&mut game, &dir.join(format!("{tag}.png")));
101                        }
102                    }
103                    mask = match label {
104                        "root" => {
105                            let pick = agenda.first().copied().unwrap_or(Pick::Fight);
106                            if !agenda.is_empty() {
107                                agenda.remove(0);
108                            }
109                            match pick {
110                                Pick::Fight => GbButton::A.bit_mask(),
111                                Pick::Party => {
112                                    keys.push(GbButton::A);
113                                    GbButton::Down.bit_mask()
114                                }
115                                Pick::Item => {
116                                    keys.push(GbButton::Down);
117                                    keys.push(GbButton::A);
118                                    GbButton::Down.bit_mask()
119                                }
120                            }
121                        }
122                        // Submenus: confirm the entry under the cursor (the
123                        // party cursor starts on the first switchable member).
124                        _ => GbButton::A.bit_mask(),
125                    };
126                } else if frame % 4 == 0 {
127                    mask = GbButton::A.bit_mask(); // page narration
128                }
129            } else if frame % 4 == 0 {
130                mask = GbButton::A.bit_mask(); // page dialogue
131            }
132        }
133        input.set_from_bitmask(mask);
134        game.update(&input);
135        input.begin_frame();
136
137        let in_battle = game.battle().is_some();
138        // Battle-start edge: print the party it starts with (the carried-over
139        // state from battle 2 on) and load its agenda.
140        if in_battle && !was_in_battle {
141            let n = battles_done + 1;
142            let battle = game.battle().unwrap();
143            println!("battle {n} starts:");
144            for c in battle.party() {
145                let status = c.status.as_deref().unwrap_or("-");
146                println!(
147                    "  {} {}/{} MP {}/{} status {}",
148                    c.name, c.hp, c.max_hp, c.mp, c.max_mp, status
149                );
150            }
151            println!("  inventory: {:?}", battle.inventory());
152            agenda = agendas[battles_done].to_vec();
153        }
154        if in_battle {
155            last_log = game.battle().unwrap().log().to_vec();
156        }
157        // Battle-end edge: the runner has harvested the party state.
158        if was_in_battle && !in_battle {
159            battles_done += 1;
160            println!("\nbattle {battles_done} log:");
161            for line in &last_log {
162                println!("  {line}");
163            }
164            if let Some(party) = game.party_state() {
165                println!("party state after battle {battles_done}:");
166                for m in party {
167                    println!("  {} hp {} mp {} status {:?}", m.id, m.hp, m.mp, m.status);
168                }
169            }
170            if let Some(inv) = game.inventory() {
171                println!("inventory: {inv:?}");
172            }
173            println!();
174            last_log.clear();
175            if battles_done == 2 {
176                println!("both battles done — stopping");
177                break;
178            }
179        }
180        was_in_battle_prev = in_battle;
181    }
182
183    if battles_done < 2 {
184        eprintln!("harness: only {battles_done} battle(s) completed within the frame cap");
185        std::process::exit(1);
186    }
187}