use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, Ordering};
use dotzuki_engine::overworld::types::Direction;
use dotzuki_renderer::input::{GbButton, InputState};
use dotzuki_runner::{LoadedProject, RunnerGame, RunnerOptions};
static NEXT_ID: AtomicU32 = AtomicU32::new(0);
struct TestDir(PathBuf);
impl TestDir {
fn new(test: &str) -> Self {
let id = NEXT_ID.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!(
"dotzuki-runner-menus-{test}-{}-{id}",
std::process::id()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
TestDir(dir)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn copy_dir(src: &Path, dst: &Path) {
fs::create_dir_all(dst).unwrap();
for entry in fs::read_dir(src).unwrap() {
let entry = entry.unwrap();
let to = dst.join(entry.file_name());
if entry.file_type().unwrap().is_dir() {
copy_dir(&entry.path(), &to);
} else {
fs::copy(entry.path(), &to).unwrap();
}
}
}
fn write_tileset(map_dir: &Path) {
let tile = 16u32;
let mut img = image::RgbaImage::new(tile * 4, tile);
for (i, &[r, g, b, a]) in [
[0xFF, 0x00, 0x00, 0xFF],
[0x00, 0xFF, 0x00, 0xFF],
[0x00, 0x00, 0xFF, 0xFF],
[0xFF, 0xFF, 0x00, 0xFF],
]
.iter()
.enumerate()
{
for y in 0..tile {
for x in 0..tile {
img.put_pixel(i as u32 * tile + x, y, image::Rgba([r, g, b, a]));
}
}
}
img.save(map_dir.join("tileset.png")).unwrap();
}
fn demo_project(test: &str) -> (TestDir, PathBuf) {
let tmp = TestDir::new(test);
let root = tmp.path().join("demo");
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/demo");
copy_dir(&fixture, &root);
write_tileset(&root.join("data/maps/Town"));
write_tileset(&root.join("data/maps/Cave"));
(tmp, root)
}
fn boot(test: &str) -> (TestDir, RunnerGame) {
let (tmp, root) = demo_project(test);
let project = LoadedProject::load(&root).expect("load demo project");
let opts = RunnerOptions {
rng_script: Some(vec![50, 100, 1]),
..RunnerOptions::default()
};
let game = RunnerGame::new(project, opts).expect("boot game");
(tmp, game)
}
fn frame(game: &mut RunnerGame, mask: u8) {
let mut input = InputState::new();
input.set_from_bitmask(mask);
game.update(&input);
}
fn idle(game: &mut RunnerGame, n: u32) {
for _ in 0..n {
frame(game, 0);
}
}
fn press(game: &mut RunnerGame, button: GbButton) {
frame(game, button.bit_mask());
idle(game, 1);
}
fn press_a(game: &mut RunnerGame) {
press(game, GbButton::A);
}
fn dismiss_dialogue(game: &mut RunnerGame) {
for _ in 0..40 {
if game.dialogue_text().is_none() && game.choice_options().is_none() {
return;
}
press_a(game);
}
panic!("dialogue did not close after 40 A presses");
}
fn write_items(root: &Path) {
let path = root.join(".dotzuki-editor.json");
let mut manifest: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
{
let activities = manifest["activities"].as_array_mut().unwrap();
let data = activities
.iter_mut()
.find(|a| a["id"] == "data")
.expect("data activity");
data["config"]["tables"].as_array_mut().unwrap().push(serde_json::json!({
"id": "items", "label": "Items", "dir": "items", "icon": "", "idField": "id",
"fields": [ {"id": "name"}, {"id": "healHp"}, {"id": "price"}, {"id": "effect"} ]
}));
}
manifest["battle"]["items"] =
serde_json::json!({ "table": "items", "healField": "healHp", "starting": { "potion": 3 } });
fs::write(&path, serde_json::to_string_pretty(&manifest).unwrap()).unwrap();
fs::create_dir_all(root.join("data/items")).unwrap();
fs::write(
root.join("data/items/potion.json"),
r#"{ "id": "potion", "name": "Potion", "healHp": 50, "price": 20, "effect": "Restores 50 HP" }"#,
)
.unwrap();
fs::write(
root.join("data/items/elixir.json"),
r#"{ "id": "elixir", "name": "Elixir", "healHp": 999, "price": 200, "effect": "Restores all HP" }"#,
)
.unwrap();
fs::write(
root.join("data/items/tent.json"),
r#"{ "id": "tent", "name": "Tent", "healHp": 0, "price": 50, "effect": "Rest at camp" }"#,
)
.unwrap();
}
fn write_shop_scene(root: &Path) {
fs::write(
root.join("data/maps/Town/script.scene"),
r#"game_scene Town {
@storylines {
@speaker("Guide") {
"Welcome to Town."
}
}
@storyline("shop_test") {
@trigger(map = "Town", npc = "Guide")
@command("openShop", ["potion", "elixir"])
@speaker("Guide") {
"Come again!"
}
}
}
"#,
)
.unwrap();
}
fn reach_boss(game: &mut RunnerGame) {
dismiss_dialogue(game); for _ in 0..8 {
frame(game, GbButton::Left.bit_mask());
}
idle(game, 2 * 10 + 2); assert_eq!(game.current_map_id(), Some("Cave"));
dismiss_dialogue(game); game.debug_place(1, 2, Direction::Up);
}
fn auto_battle(game: &mut RunnerGame) -> Vec<String> {
let mut narration: Vec<String> = Vec::new();
for _ in 0..120 {
let Some(battle) = game.battle() else { break };
if let Some(line) = battle.current_line() {
narration.push(line.to_string());
}
press_a(game);
}
narration
}
#[test]
fn start_menu_opens_and_closes() {
let (_tmp, mut game) = boot("openclose");
dismiss_dialogue(&mut game); assert!(game.menu_lines().is_none(), "no menu in the overworld");
press(&mut game, GbButton::Start);
assert_eq!(
game.menu_lines().expect("menu open"),
vec!["Party", "Bag", "Save", "Close"]
);
press(&mut game, GbButton::B);
assert!(game.menu_lines().is_none(), "B closed the menu");
press(&mut game, GbButton::Start);
assert!(game.menu_lines().is_some(), "Start reopens the menu");
for _ in 0..3 {
press(&mut game, GbButton::Down);
}
press_a(&mut game); assert!(game.menu_lines().is_none(), "Close entry closed the menu");
game.debug_place(1, 1, Direction::Right);
press_a(&mut game);
assert!(
game.dialogue_text().is_some(),
"menu close unfreezes the overworld"
);
}
#[test]
fn party_view_shows_record_details() {
let (_tmp, mut game) = boot("partyview");
dismiss_dialogue(&mut game);
press(&mut game, GbButton::Start);
press_a(&mut game);
let lines = game.menu_lines().expect("party view open");
assert!(
lines.contains(&"Aria HP 60/60 MP 20/20".to_string()),
"{lines:?}"
);
assert!(lines.contains(&"ATK 12 DEF 10 SPD 15 grass".to_string()), "{lines:?}");
assert!(
lines.contains(&"Skills: Slash, Fire Bolt, Heal, Focus".to_string()),
"{lines:?}"
);
press(&mut game, GbButton::B);
assert_eq!(
game.menu_lines().expect("back at root"),
vec!["Party", "Bag", "Save", "Close"]
);
}
fn boot_from_hurt_save(test: &str) -> (TestDir, RunnerGame) {
let (tmp, root) = demo_project(test);
write_items(&root);
fs::write(
root.join("data/heroes/bryn.json"),
r#"{
"id": "bryn",
"name": "Bryn",
"hp": 80,
"atk": 14,
"def": 12,
"spd": 10,
"mp": 60,
"element": "fire",
"skills": ["fire-bolt", "slash"]
}"#,
)
.unwrap();
let save_file = root.join("test-save.json");
fs::write(
&save_file,
r#"{
"version": 3,
"map": "Town",
"player": { "x": 1, "y": 1, "facing": "down" },
"flags": { "__played_main_Town": true },
"party": [
{ "id": "aria", "hp": 32, "mp": 20 },
{ "id": "bryn", "hp": 0, "mp": 60 }
],
"inventory": { "potion": 2, "tent": 1 },
"money": 100
}"#,
)
.unwrap();
let project = LoadedProject::load(&root).expect("load demo project");
let opts = RunnerOptions {
save_file: Some(save_file),
rng_script: Some(vec![50, 100, 1]),
..RunnerOptions::default()
};
let game = RunnerGame::new(project, opts).expect("boot from save");
assert_eq!(game.player_tile(), (1, 1), "resumed at the saved tile");
(tmp, game)
}
fn open_bag_at(game: &mut RunnerGame, row: usize) {
press(game, GbButton::Start);
press(game, GbButton::Down); press_a(game);
for _ in 0..row {
press(game, GbButton::Down);
}
}
#[test]
fn bag_heal_caps_at_max_and_decrements() {
let (_tmp, mut game) = boot_from_hurt_save("bagheal");
assert_eq!(game.money(), 100, "money from the save");
open_bag_at(&mut game, 0);
assert_eq!(
game.menu_lines().expect("bag open"),
vec!["Potion ×2".to_string(), "Tent ×1".to_string()]
);
press_a(&mut game);
assert_eq!(
game.menu_lines().expect("target pick"),
vec!["Aria HP 32/60".to_string(), "× Bryn HP 0/80".to_string()]
);
press_a(&mut game);
assert_eq!(
game.menu_lines().expect("heal note"),
vec!["Aria recovered 28 HP!".to_string()]
);
press_a(&mut game); assert_eq!(
game.menu_lines().expect("bag again"),
vec!["Potion ×1".to_string(), "Tent ×1".to_string()]
);
press(&mut game, GbButton::B); press(&mut game, GbButton::B); assert!(game.menu_lines().is_none());
let party = game.party_state().expect("party state");
assert_eq!(party[0].hp, 60, "healed (capped at max)");
assert_eq!(party[1].hp, 0, "Bryn untouched");
assert_eq!(game.inventory().unwrap().get("potion"), Some(&1));
}
#[test]
fn bag_heal_does_not_revive_fainted_member() {
let (_tmp, mut game) = boot_from_hurt_save("bagfaint");
open_bag_at(&mut game, 0);
press_a(&mut game); press(&mut game, GbButton::Down); press_a(&mut game);
assert_eq!(
game.menu_lines().expect("rejection note"),
vec!["It won't have any effect.".to_string()]
);
press_a(&mut game); assert_eq!(
game.inventory().unwrap().get("potion"),
Some(&2),
"the rejected heal kept the item"
);
assert_eq!(game.party_state().unwrap()[1].hp, 0, "still fainted");
}
#[test]
fn bag_unusable_item_shows_no_effect() {
let (_tmp, mut game) = boot_from_hurt_save("bagtent");
open_bag_at(&mut game, 1); press_a(&mut game);
assert_eq!(
game.menu_lines().expect("no-effect note"),
vec!["It won't have any effect.".to_string()]
);
}
#[test]
fn save_from_menu_writes_a_resumable_file() {
let (_tmp, root) = demo_project("menusave");
let save_file = root.join("menu-save.json");
let project = LoadedProject::load(&root).expect("load demo project");
let opts = RunnerOptions {
save_file: Some(save_file.clone()),
..RunnerOptions::default()
};
let mut game = RunnerGame::new(project, opts).expect("boot");
dismiss_dialogue(&mut game);
press(&mut game, GbButton::Start);
press(&mut game, GbButton::Down);
press(&mut game, GbButton::Down); press_a(&mut game);
assert_eq!(
game.menu_lines().expect("save confirmation"),
vec!["Game saved.".to_string()]
);
let text = fs::read_to_string(&save_file).expect("menu save wrote the file");
let raw: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(raw["version"], 3);
assert_eq!(raw["map"], "Town");
assert_eq!(raw["money"], 100, "default money persisted");
let project = LoadedProject::load(&root).expect("load demo project");
let opts = RunnerOptions {
save_file: Some(save_file),
..RunnerOptions::default()
};
let game = RunnerGame::new(project, opts).expect("boot from the menu save");
assert_eq!(game.current_map_id(), Some("Town"));
assert!(game.flag("__played_main_Town"));
assert_eq!(game.money(), 100);
}
fn open_shop(test: &str) -> (TestDir, RunnerGame) {
let (tmp, root) = demo_project(test);
write_items(&root);
write_shop_scene(&root);
let project = LoadedProject::load(&root).expect("load demo project");
let game = RunnerGame::new(project, RunnerOptions::default()).expect("boot");
(tmp, game)
}
fn talk_to_guide(game: &mut RunnerGame) {
dismiss_dialogue(game); game.debug_place(1, 1, Direction::Right); press_a(game); }
#[test]
fn shop_buy_flow_and_exit_resumes_scene() {
let (_tmp, mut game) = open_shop("shopbuy");
talk_to_guide(&mut game);
let rows = game.shop_lines().expect("shop open");
assert_eq!(
rows,
vec!["Buy".to_string(), "Sell".to_string(), "Exit".to_string()],
"{rows:?}"
);
assert_eq!(game.money(), 100);
press_a(&mut game);
let rows = game.shop_lines().expect("buy view");
assert_eq!(
rows,
vec!["Potion 20 G".to_string(), "× Elixir 200 G".to_string()],
"{rows:?}"
);
press_a(&mut game);
assert_eq!(
game.shop_lines().unwrap().last(),
Some(&"Bought a Potion!".to_string())
);
press_a(&mut game); press_a(&mut game); press_a(&mut game); assert_eq!(game.money(), 60);
assert_eq!(game.inventory().unwrap().get("potion"), Some(&5));
press(&mut game, GbButton::Down);
press_a(&mut game);
assert_eq!(
game.shop_lines().unwrap().last(),
Some(&"Not enough money…".to_string())
);
assert_eq!(game.money(), 60, "blocked buys don't charge");
assert_eq!(game.inventory().unwrap().get("elixir"), None);
press_a(&mut game);
press(&mut game, GbButton::B);
assert_eq!(
game.shop_lines().expect("back at the root"),
vec!["Buy".to_string(), "Sell".to_string(), "Exit".to_string()]
);
press(&mut game, GbButton::B);
assert!(game.shop_lines().is_none(), "shop closed");
let page = game.dialogue_text().expect("scene resumed");
assert!(page.contains("Come again!"), "page: {page:?}");
dismiss_dialogue(&mut game);
}
#[test]
fn shop_sell_flow_money_math_and_counts() {
let (_tmp, mut game) = open_shop("shopsell");
talk_to_guide(&mut game);
press(&mut game, GbButton::Down);
press_a(&mut game);
let rows = game.shop_lines().expect("sell view");
assert_eq!(rows, vec!["Potion ×3 10 G".to_string()], "{rows:?}");
press_a(&mut game);
assert_eq!(
game.shop_lines().unwrap().last(),
Some(&"Sold a Potion!".to_string())
);
press_a(&mut game); assert_eq!(game.money(), 110);
assert_eq!(game.inventory().unwrap().get("potion"), Some(&2));
press_a(&mut game); press_a(&mut game); assert_eq!(game.money(), 120);
assert_eq!(game.inventory().unwrap().get("potion"), Some(&1));
press_a(&mut game); press_a(&mut game); assert_eq!(game.money(), 130);
assert_eq!(game.inventory().unwrap().get("potion"), None);
assert_eq!(
game.shop_lines().expect("empty sell view"),
vec!["Nothing to sell.".to_string()]
);
press(&mut game, GbButton::B);
press(&mut game, GbButton::Down);
press(&mut game, GbButton::Down);
press_a(&mut game); let page = game.dialogue_text().expect("scene resumed");
assert!(page.contains("Come again!"), "page: {page:?}");
dismiss_dialogue(&mut game);
}
#[test]
fn shop_sell_zero_priced_item_sells_for_zero() {
let (tmp, root) = demo_project("sellzero");
write_items(&root);
write_shop_scene(&root);
fs::write(
root.join("data/items/pebble.json"),
r#"{ "id": "pebble", "name": "Pebble", "healHp": 0, "price": 0, "effect": "Just a rock" }"#,
)
.unwrap();
let path = root.join(".dotzuki-editor.json");
let mut manifest: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
manifest["battle"]["items"]["starting"] = serde_json::json!({ "pebble": 1, "potion": 1 });
fs::write(&path, serde_json::to_string_pretty(&manifest).unwrap()).unwrap();
let project = LoadedProject::load(&root).expect("load demo project");
let mut game = RunnerGame::new(project, RunnerOptions::default()).expect("boot");
talk_to_guide(&mut game);
press(&mut game, GbButton::Down);
press_a(&mut game);
let rows = game.shop_lines().expect("sell view");
assert_eq!(
rows,
vec!["Pebble ×1 0 G".to_string(), "Potion ×1 10 G".to_string()],
"{rows:?}"
);
press_a(&mut game);
assert_eq!(
game.shop_lines().unwrap().last(),
Some(&"Sold a Pebble!".to_string())
);
assert_eq!(game.money(), 100, "price 0 sells for 0");
assert_eq!(game.inventory().unwrap().get("pebble"), None);
let _ = tmp;
}
#[test]
fn shop_section_seeds_start_money() {
let (tmp, root) = demo_project("shopsection");
write_items(&root);
write_shop_scene(&root);
let path = root.join(".dotzuki-editor.json");
let mut manifest: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
manifest["shop"] = serde_json::json!({ "currency": "G", "startMoney": 15 });
fs::write(&path, serde_json::to_string_pretty(&manifest).unwrap()).unwrap();
let project = LoadedProject::load(&root).expect("load demo project");
let mut game = RunnerGame::new(project, RunnerOptions::default()).expect("boot");
assert_eq!(game.money(), 15, "startMoney from the shop section");
talk_to_guide(&mut game);
press_a(&mut game); assert_eq!(
game.shop_lines().expect("buy view"),
vec!["× Potion 20 G".to_string(), "× Elixir 200 G".to_string()]
);
press_a(&mut game);
assert_eq!(
game.shop_lines().unwrap().last(),
Some(&"Not enough money…".to_string())
);
assert_eq!(game.money(), 15);
let _ = tmp;
}
#[test]
fn lost_battle_triggers_whiteout_and_respawns_at_entry() {
let (_tmp, root) = demo_project("whiteout");
fs::write(
root.join("data/monsters/slime.json"),
r#"{
"id": "slime",
"name": "Slime",
"hp": 200,
"atk": 60,
"def": 50,
"spd": 99,
"mp": 0,
"element": "grass",
"skills": []
}"#,
)
.unwrap();
let project = LoadedProject::load(&root).expect("load demo project");
let opts = RunnerOptions {
rng_script: Some(vec![50, 100, 1]),
..RunnerOptions::default()
};
let mut game = RunnerGame::new(project, opts).expect("boot");
reach_boss(&mut game);
press_a(&mut game); press_a(&mut game); let narration = auto_battle(&mut game);
assert_eq!(narration.last(), Some(&"You lost the battle…".to_string()), "{narration:?}");
let page = game.dialogue_text().expect("post-lose text");
assert!(page.contains("The Slime oozes onward"), "page: {page:?}");
assert!(!game.whiteout_active(), "whiteout waits for the scene to finish");
press_a(&mut game); assert!(game.whiteout_active(), "whiteout armed");
assert!(game.dialogue_text().is_none(), "blackout phase shows no text");
idle(&mut game, 35);
let page = game.dialogue_text().expect("whiteout message");
assert!(page.contains("Aria collapsed"), "page: {page:?}");
press_a(&mut game);
assert!(!game.whiteout_active());
assert_eq!(game.current_map_id(), Some("Town"), "returned to the entry map");
assert_eq!(game.player_tile(), (1, 1), "the entry spawn");
let party = game.party_state().expect("party state");
assert_eq!(party.len(), 1);
assert_eq!((party[0].hp, party[0].mp), (60, 20), "healed to full");
assert_eq!(party[0].status, None, "status cleared");
assert!(game.flag("__played_main_Town"), "flags kept");
}
#[test]
fn whiteout_heals_only_when_project_has_no_maps() {
let (tmp, root) = demo_project("maplesswo");
fs::remove_dir_all(root.join("data/maps")).unwrap();
fs::write(
root.join("data/monsters/slime.json"),
r#"{
"id": "slime",
"name": "Slime",
"hp": 200,
"atk": 60,
"def": 50,
"spd": 99,
"mp": 0,
"element": "grass",
"skills": []
}"#,
)
.unwrap();
fs::write(
root.join("assets/scenes/main.scene"),
r#"game_scene Main {
@storylines {
@command("startBattle", "slime")
@speaker("Guide") {
"That went badly."
}
}
}
"#,
)
.unwrap();
let project = LoadedProject::load(&root).expect("load demo project");
let opts = RunnerOptions {
rng_script: Some(vec![50, 100, 1]),
..RunnerOptions::default()
};
let mut game = RunnerGame::new(project, opts).expect("boot");
assert_eq!(game.current_map_id(), None);
let narration = auto_battle(&mut game);
assert_eq!(narration.last(), Some(&"You lost the battle…".to_string()), "{narration:?}");
dismiss_dialogue(&mut game); idle(&mut game, 35);
let page = game.dialogue_text().expect("whiteout message");
assert!(page.contains("collapsed"), "page: {page:?}");
press_a(&mut game);
assert_eq!(game.current_map_id(), None, "no maps: nowhere to return to");
let party = game.party_state().expect("party state");
assert_eq!((party[0].hp, party[0].mp), (60, 20), "healed to full");
let _ = tmp;
}