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.next().expect("usage: menu_accept <project-dir> [shot-dir]");
79    let shot_dir = args.next().map(PathBuf::from);
80
81    let project = LoadedProject::load(Path::new(&dir)).expect("load project");
82    let mut game = RunnerGame::new(
83        project,
84        RunnerOptions {
85            headless: true,
86            fresh: true,
87            // Deterministic battles: always hit, 89% variance, never crit.
88            rng_script: Some(vec![50, 100, 1]),
89            ..RunnerOptions::default()
90        },
91    )
92    .expect("boot game");
93
94    dismiss(&mut game); // StartTown main
95    println!("== 1. the shop ==");
96    println!("money at boot: {} (manifest shop.startMoney)", game.money());
97
98    // Talk to the Shopkeeper at (13,10): the shop opens on the spot.
99    game.debug_place(12, 10, Direction::Right);
100    press_a(&mut game);
101    let rows = game.shop_lines().expect("shop open");
102    println!("shop shelf: {rows:?}");
103    snap(&mut game, &shot_dir, "shop");
104
105    // Buy one Potion: 100 → 80 G, inventory 3 → 4.
106    let before = game.money();
107    press_a(&mut game);
108    let rows = game.shop_lines().expect("shop still open");
109    println!(
110        "bought a Potion: money {before} → {} G; inventory {:?}",
111        game.money(),
112        game.inventory().expect("inventory materialized")
113    );
114    println!("shop note: {:?}", rows.last());
115    snap(&mut game, &shot_dir, "shop-bought");
116    assert_eq!(game.money(), 80, "20 G charged");
117    assert_eq!(game.inventory().unwrap().get("potion"), Some(&4));
118    press_a(&mut game); // dismiss the note
119
120    // B exits; the scene resumes with its follow-up line.
121    press(&mut game, GbButton::B);
122    let page = game.dialogue_text().expect("scene resumed after the shop");
123    println!("scene resumed with: {page:?}");
124    assert!(page.contains("Come again!"), "page: {page:?}");
125    dismiss(&mut game);
126
127    println!("\n== 2. the whiteout ==");
128    // Talk to the Hermit at (11,10) and lose on purpose (overwhelming Slime).
129    game.debug_place(12, 10, Direction::Left);
130    press_a(&mut game); // "Something stirs…"
131    press_a(&mut game); // → battle
132    let mut last_log: Vec<String> = Vec::new();
133    for _ in 0..120 {
134        let Some(battle) = game.battle() else { break };
135        last_log = battle.log().to_vec();
136        press_a(&mut game);
137    }
138    println!("lost battle log:");
139    for line in &last_log {
140        println!("  {line}");
141    }
142    assert_eq!(last_log.last().map(String::as_str), Some("You lost the battle…"));
143
144    // The scene's post-lose text still plays…
145    let page = game.dialogue_text().expect("post-lose text");
146    println!("post-lose text: {page:?}");
147    assert!(page.contains("no match"), "page: {page:?}");
148    press_a(&mut game); // scene finishes → whiteout
149
150    // …then the whiteout: blackout first, then the collapsed line.
151    assert!(game.whiteout_active(), "whiteout armed");
152    idle(&mut game, 10);
153    snap(&mut game, &shot_dir, "whiteout-blackout");
154    idle(&mut game, 25);
155    let page = game.dialogue_text().expect("whiteout message");
156    println!("whiteout message: {page:?}");
157    assert!(page.contains("collapsed"), "page: {page:?}");
158    snap(&mut game, &shot_dir, "whiteout-message");
159
160    // Landing it: party healed to full, back at the entry spawn, flags kept.
161    press_a(&mut game);
162    assert!(!game.whiteout_active());
163    println!("after the whiteout:");
164    println!("  map: {:?} tile: {:?}", game.current_map_id(), game.player_tile());
165    for m in game.party_state().expect("party state") {
166        println!("  {} hp {} mp {} status {:?}", m.id, m.hp, m.mp, m.status);
167    }
168    println!("  __played_main_StartTown flag kept: {}", game.flag("__played_main_StartTown"));
169    assert_eq!(game.current_map_id(), Some("StartTown"));
170    assert!(game
171        .party_state()
172        .unwrap()
173        .iter()
174        .all(|m| m.hp > 0 && m.status.is_none()));
175    snap(&mut game, &shot_dir, "whiteout-after");
176
177    println!("\nacceptance OK");
178}