Skip to main content

menu_accept/
menu_accept.rs

1//! Menus/shops/game-over acceptance harness: boots a zero-Rust project
2//! headless and drives, as evidence:
3//!
4//! 1. the **shop flow** — talk to the Shopkeeper, screenshot the shop UI,
5//!    buy a Potion (money math + inventory logged), exit, and show the
6//!    scene's follow-up text resume;
7//! 2. the **game-over whiteout** — lose the Hermit's battle on purpose,
8//!    show the scene's post-lose text, screenshot the blackout and the
9//!    "collapsed" message, then log the healed party and the respawn at the
10//!    entry map's spawn (flags kept).
11//!
12//! Usage:
13//!
14//! ```sh
15//! cargo run -p dotzuki-runner --example menu_accept -- <project-dir> [shot-dir]
16//! ```
17//!
18//! The project is expected to be a scaffolded `dotzuki` template with the
19//! acceptance patch applied (Shopkeeper at (13,10), Hermit at (11,10), an
20//! overwhelming Slime — see the feature branch's acceptance notes).
21
22use std::path::{Path, PathBuf};
23
24use dotzuki_engine::overworld::types::Direction;
25use dotzuki_engine::render::{FrameBuffer, Rgba};
26use dotzuki_engine::render_config::RenderConfig;
27use dotzuki_renderer::input::{GbButton, InputState};
28use dotzuki_runner::headless::save_png;
29use dotzuki_runner::{LoadedProject, RunnerGame, RunnerOptions, SCREEN_H, SCREEN_W};
30
31/// Draw the current frame into a PNG.
32fn snap(game: &mut RunnerGame, dir: &Option<PathBuf>, name: &str) {
33    let Some(dir) = dir else { return };
34    let mut fb = FrameBuffer::new(
35        RenderConfig::new(SCREEN_W as u32, SCREEN_H as u32),
36        Rgba::BLACK,
37    );
38    game.draw(&mut fb);
39    let path = dir.join(format!("{name}.png"));
40    save_png(&fb, &path).expect("screenshot");
41    println!("  [shot] {}", path.display());
42}
43
44fn frame(game: &mut RunnerGame, mask: u8) {
45    let mut input = InputState::new();
46    input.set_from_bitmask(mask);
47    game.update(&input);
48}
49
50fn idle(game: &mut RunnerGame, n: u32) {
51    for _ in 0..n {
52        frame(game, 0);
53    }
54}
55
56fn press(game: &mut RunnerGame, button: GbButton) {
57    frame(game, button.bit_mask());
58    idle(game, 1);
59}
60
61fn press_a(game: &mut RunnerGame) {
62    press(game, GbButton::A);
63}
64
65/// Press A until no textbox/choice/whiteout message is up (bounded).
66fn dismiss(game: &mut RunnerGame) {
67    for _ in 0..40 {
68        if game.dialogue_text().is_none() && game.choice_options().is_none() {
69            return;
70        }
71        press_a(game);
72    }
73    panic!("dialogue did not close");
74}
75
76fn main() {
77    let mut args = std::env::args().skip(1);
78    let dir = args
79        .next()
80        .expect("usage: menu_accept <project-dir> [shot-dir]");
81    let shot_dir = args.next().map(PathBuf::from);
82
83    let project = LoadedProject::load(Path::new(&dir)).expect("load project");
84    let mut game = RunnerGame::new(
85        project,
86        RunnerOptions {
87            headless: true,
88            fresh: true,
89            // Deterministic battles: always hit, 89% variance, never crit.
90            rng_script: Some(vec![50, 100, 1]),
91            ..RunnerOptions::default()
92        },
93    )
94    .expect("boot game");
95
96    dismiss(&mut game); // StartTown main
97    println!("== 1. the shop ==");
98    println!("money at boot: {} (manifest shop.startMoney)", game.money());
99
100    // Talk to the Shopkeeper at (13,10): the shop opens on the spot.
101    game.debug_place(12, 10, Direction::Right);
102    press_a(&mut game);
103    let rows = game.shop_lines().expect("shop open");
104    println!("shop shelf: {rows:?}");
105    snap(&mut game, &shot_dir, "shop");
106
107    // Buy one Potion: 100 → 80 G, inventory 3 → 4.
108    let before = game.money();
109    press_a(&mut game);
110    let rows = game.shop_lines().expect("shop still open");
111    println!(
112        "bought a Potion: money {before} → {} G; inventory {:?}",
113        game.money(),
114        game.inventory().expect("inventory materialized")
115    );
116    println!("shop note: {:?}", rows.last());
117    snap(&mut game, &shot_dir, "shop-bought");
118    assert_eq!(game.money(), 80, "20 G charged");
119    assert_eq!(game.inventory().unwrap().get("potion"), Some(&4));
120    press_a(&mut game); // dismiss the note
121
122    // B exits; the scene resumes with its follow-up line.
123    press(&mut game, GbButton::B);
124    let page = game.dialogue_text().expect("scene resumed after the shop");
125    println!("scene resumed with: {page:?}");
126    assert!(page.contains("Come again!"), "page: {page:?}");
127    dismiss(&mut game);
128
129    println!("\n== 2. the whiteout ==");
130    // Talk to the Hermit at (11,10) and lose on purpose (overwhelming Slime).
131    game.debug_place(12, 10, Direction::Left);
132    press_a(&mut game); // "Something stirs…"
133    press_a(&mut game); // → battle
134    let mut last_log: Vec<String> = Vec::new();
135    for _ in 0..120 {
136        let Some(battle) = game.battle() else { break };
137        last_log = battle.log().to_vec();
138        press_a(&mut game);
139    }
140    println!("lost battle log:");
141    for line in &last_log {
142        println!("  {line}");
143    }
144    assert_eq!(
145        last_log.last().map(String::as_str),
146        Some("You lost the battle…")
147    );
148
149    // The scene's post-lose text still plays…
150    let page = game.dialogue_text().expect("post-lose text");
151    println!("post-lose text: {page:?}");
152    assert!(page.contains("no match"), "page: {page:?}");
153    press_a(&mut game); // scene finishes → whiteout
154
155    // …then the whiteout: blackout first, then the collapsed line.
156    assert!(game.whiteout_active(), "whiteout armed");
157    idle(&mut game, 10);
158    snap(&mut game, &shot_dir, "whiteout-blackout");
159    idle(&mut game, 25);
160    let page = game.dialogue_text().expect("whiteout message");
161    println!("whiteout message: {page:?}");
162    assert!(page.contains("collapsed"), "page: {page:?}");
163    snap(&mut game, &shot_dir, "whiteout-message");
164
165    // Landing it: party healed to full, back at the entry spawn, flags kept.
166    press_a(&mut game);
167    assert!(!game.whiteout_active());
168    println!("after the whiteout:");
169    println!(
170        "  map: {:?} tile: {:?}",
171        game.current_map_id(),
172        game.player_tile()
173    );
174    for m in game.party_state().expect("party state") {
175        println!("  {} hp {} mp {} status {:?}", m.id, m.hp, m.mp, m.status);
176    }
177    println!(
178        "  __played_main_StartTown flag kept: {}",
179        game.flag("__played_main_StartTown")
180    );
181    assert_eq!(game.current_map_id(), Some("StartTown"));
182    assert!(game
183        .party_state()
184        .unwrap()
185        .iter()
186        .all(|m| m.hp > 0 && m.status.is_none()));
187    snap(&mut game, &shot_dir, "whiteout-after");
188
189    println!("\nacceptance OK");
190}