Skip to main content

levels_accept/
levels_accept.rs

1//! EXP/levels (v2-c) acceptance harness: boots a zero-Rust project headless
2//! and drives two consecutive battles, printing as evidence:
3//!
4//! 1. battle 1's narration — the EXP award + level-up lines after the win
5//!    text, and the heal-the-delta pools in the harvested party state;
6//! 2. battle 2 starting from the GROWN stats (level 2, higher max HP);
7//! 3. the Start menu's Party view showing `Lv` + the EXP progress line;
8//! 4. a save round trip — level/exp resume into a fresh boot.
9//!
10//! Usage:
11//!
12//! ```sh
13//! cargo run -p dotzuki-runner --example levels_accept -- <project-dir> [shot-dir]
14//! ```
15//!
16//! The project should trigger two `startBattle` commands in a row (the
17//! acceptance patches StartTown's `script.scene` accordingly) and carry a
18//! `battle.levels` block (the scaffolded jrpg template ships one).
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/// Draw the current frame into a PNG.
29fn snap(game: &mut RunnerGame, dir: &Option<PathBuf>, name: &str) {
30    let Some(dir) = dir else { return };
31    let mut fb = FrameBuffer::new(
32        RenderConfig::new(SCREEN_W as u32, SCREEN_H as u32),
33        Rgba::BLACK,
34    );
35    game.draw(&mut fb);
36    let path = dir.join(format!("{name}.png"));
37    save_png(&fb, &path).expect("screenshot");
38    println!("  [shot] {}", path.display());
39}
40
41fn frame(game: &mut RunnerGame, mask: u8) {
42    let mut input = InputState::new();
43    input.set_from_bitmask(mask);
44    game.update(&input);
45}
46
47fn idle(game: &mut RunnerGame, n: u32) {
48    for _ in 0..n {
49        frame(game, 0);
50    }
51}
52
53fn press_a(game: &mut RunnerGame) {
54    frame(game, GbButton::A.bit_mask());
55    idle(game, 1);
56}
57
58fn print_party(game: &RunnerGame, when: &str) {
59    let Some(party) = game.party_state() else {
60        return;
61    };
62    println!("party state {when}:");
63    for m in party {
64        println!(
65            "  {} level {} exp {} hp {} mp {} status {:?}",
66            m.id, m.level, m.exp, m.hp, m.mp, m.status
67        );
68    }
69}
70
71fn main() {
72    let mut args = std::env::args().skip(1);
73    let dir = args
74        .next()
75        .expect("usage: levels_accept <project-dir> [shot-dir]");
76    let shot_dir = args.next().map(PathBuf::from);
77
78    let save_file = Path::new(&dir).join(".dotzuki-save.json");
79    let project = LoadedProject::load(Path::new(&dir)).expect("load project");
80    let mut game = RunnerGame::new(
81        project,
82        RunnerOptions {
83            headless: true,
84            fresh: true,
85            write_saves: true,
86            external_saves: false,
87            save_file: Some(save_file.clone()),
88            // Deterministic battles: always hit, 89% variance, never crit.
89            rng_script: Some(vec![50, 100, 1]),
90            ..RunnerOptions::default()
91        },
92    )
93    .expect("boot game");
94
95    // Auto-A drives everything: dialogue pages, battle narration, and the
96    // Fight → first-skill picks. Back-to-back `startBattle` commands swap
97    // Battle→Battle INSIDE one update, so a new battle is detected by the
98    // log resetting, not just by a not-in-battle gap.
99    let mut battle_no = 0usize;
100    let mut completed = 0usize;
101    let mut last_log: Vec<String> = Vec::new();
102    let mut was_in_battle = false;
103    let mut snapped_battle = false;
104
105    for frame_no in 0..6000u32 {
106        if frame_no % 4 == 0 {
107            frame(&mut game, GbButton::A.bit_mask());
108        } else {
109            idle(&mut game, 1);
110        }
111
112        let in_battle = game.battle().is_some();
113        if in_battle {
114            let log_len = game.battle().unwrap().log().len();
115            let new_battle = !was_in_battle || log_len < last_log.len();
116            if new_battle {
117                if was_in_battle {
118                    // Seamless Battle→Battle swap: close the previous one
119                    // out (the party state is already harvested).
120                    completed += 1;
121                    println!("\nbattle {completed} log:");
122                    for line in &last_log {
123                        println!("  {line}");
124                    }
125                    print_party(&game, &format!("after battle {completed}"));
126                    println!();
127                    if completed == 2 {
128                        break;
129                    }
130                }
131                battle_no += 1;
132                let battle = game.battle().unwrap();
133                println!("battle {battle_no} starts:");
134                for c in battle.party() {
135                    println!(
136                        "  {} level {} exp {} hp {}/{} mp {}/{} atk {}",
137                        c.name, c.level, c.exp, c.hp, c.max_hp, c.mp, c.max_mp, c.attack
138                    );
139                }
140                if battle_no == 2 && !snapped_battle {
141                    snapped_battle = true;
142                    snap(&mut game, &shot_dir, "battle2-start");
143                }
144            }
145            last_log = game.battle().unwrap().log().to_vec();
146        } else if was_in_battle {
147            completed += 1;
148            println!("\nbattle {completed} log:");
149            for line in &last_log {
150                println!("  {line}");
151            }
152            print_party(&game, &format!("after battle {completed}"));
153            println!();
154            last_log.clear();
155            if completed == 2 {
156                break;
157            }
158        }
159        was_in_battle = in_battle;
160    }
161    if completed < 2 {
162        eprintln!("harness: only {completed} battle(s) completed within the frame cap");
163        std::process::exit(1);
164    }
165
166    // Dismiss the post-battle text so the scene finishes (the save is
167    // written at that stable point).
168    for _ in 0..40 {
169        if game.dialogue_text().is_none() {
170            break;
171        }
172        press_a(&mut game);
173    }
174    assert!(game.flag("SLIMES_BEATEN"), "the scene ran to its end");
175
176    // The Party view: Lv + EXP progress on the member rows.
177    frame(&mut game, GbButton::Start.bit_mask());
178    idle(&mut game, 1);
179    press_a(&mut game); // Party
180    println!("party view:");
181    for line in game.menu_lines().expect("party view open") {
182        println!("  {line}");
183    }
184    snap(&mut game, &shot_dir, "party-view");
185    frame(&mut game, GbButton::B.bit_mask());
186    idle(&mut game, 1);
187    frame(&mut game, GbButton::B.bit_mask());
188    idle(&mut game, 1);
189
190    // Save round trip: a fresh boot resumes level/exp from the save.
191    println!("\nsave round trip ({}):", save_file.display());
192    let project = LoadedProject::load(Path::new(&dir)).expect("reload project");
193    let game = RunnerGame::new(
194        project,
195        RunnerOptions {
196            headless: true,
197            save_file: Some(save_file),
198            rng_script: Some(vec![50, 100, 1]),
199            ..RunnerOptions::default()
200        },
201    )
202    .expect("boot from save");
203    print_party(&game, "resumed from the save");
204    assert!(
205        game.party_state().is_some_and(|p| p[0].level > 1),
206        "level must survive the save round trip"
207    );
208    println!("\nacceptance OK");
209}