use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU32, Ordering};
use dotzuki_engine::overworld::types::Direction;
use dotzuki_engine::render::{FrameBuffer, Rgba};
use dotzuki_engine::render_config::RenderConfig;
use dotzuki_renderer::input::{GbButton, InputState};
use dotzuki_runner::{run_headless, HeadlessOptions, 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-game-{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();
}
}
}
const TILE_COLORS: [[u8; 4]; 4] = [
[0xFF, 0x00, 0x00, 0xFF],
[0x00, 0xFF, 0x00, 0xFF],
[0x00, 0x00, 0xFF, 0xFF],
[0xFF, 0xFF, 0x00, 0xFF],
];
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 TILE_COLORS.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 boot(test: &str) -> (TestDir, RunnerGame) {
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"));
let project = LoadedProject::load(&root).expect("load demo project");
let game = RunnerGame::new(project, RunnerOptions::default()).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_a(game: &mut RunnerGame) {
frame(game, GbButton::A.bit_mask());
idle(game, 1);
}
fn dismiss_dialogue(game: &mut RunnerGame) {
for _ in 0..20 {
if game.dialogue_text().is_none() && game.choice_options().is_none() {
return;
}
press_a(game);
}
panic!("dialogue did not close after 20 A presses");
}
#[test]
fn headless_boot_renders_nonuniform_framebuffer() {
let (_tmp, mut game) = boot("headless");
let fb = run_headless(&mut game, &HeadlessOptions::default()).expect("headless run");
assert_eq!(fb.width(), 320);
assert_eq!(fb.height(), 240);
let colors: HashSet<[u8; 4]> = fb
.data
.chunks_exact(4)
.map(|px| [px[0], px[1], px[2], px[3]])
.collect();
assert!(
colors.len() > 4,
"framebuffer should not be uniform (got {} colours)",
colors.len()
);
}
#[test]
fn town_main_storyline_autoplays_on_boot() {
let (_tmp, mut game) = boot("main");
let page = game.dialogue_text().expect("main storyline shows a textbox");
assert!(page.contains("Welcome to Town"), "page: {page:?}");
dismiss_dialogue(&mut game);
assert!(game.flag("__played_main_Town"));
assert!(game.dialogue_text().is_none());
idle(&mut game, 10);
assert!(game.dialogue_text().is_none());
}
#[test]
fn stepping_on_warp_tile_loads_cave() {
let (_tmp, mut game) = boot("warp");
dismiss_dialogue(&mut game); assert_eq!(game.current_map_id(), Some("Town"));
assert_eq!(game.player_tile(), (1, 1));
for _ in 0..8 {
frame(&mut game, GbButton::Left.bit_mask());
}
idle(&mut game, 2 * 10 + 2);
assert_eq!(game.current_map_id(), Some("Cave"));
assert_eq!(game.player_tile(), (3, 4), "warp dest from objects.json");
let page = game.dialogue_text().expect("cave_enter on_enter plays");
assert!(page.contains("cold wind"), "page: {page:?}");
}
#[test]
fn hermit_choice_sets_flag_for_selected_branch() {
let (_tmp, mut game) = boot("choice");
dismiss_dialogue(&mut game); for _ in 0..8 {
frame(&mut game, GbButton::Left.bit_mask());
}
idle(&mut game, 2 * 10 + 2);
dismiss_dialogue(&mut game); assert_eq!(game.current_map_id(), Some("Cave"));
game.debug_place(2, 3, Direction::Up);
press_a(&mut game);
let page = game.dialogue_text().expect("hermit speaks");
assert!(page.contains("torch"), "page: {page:?}");
press_a(&mut game);
let options = game.choice_options().expect("choice menu open").to_vec();
assert_eq!(options, vec!["Take it".to_string(), "Leave it".to_string()]);
frame(&mut game, GbButton::Down.bit_mask());
idle(&mut game, 1);
press_a(&mut game); dismiss_dialogue(&mut game); assert!(!game.flag("TOOK_TORCH"), "declining sets no flag");
press_a(&mut game);
press_a(&mut game); assert!(game.choice_options().is_some());
press_a(&mut game); dismiss_dialogue(&mut game); assert!(game.flag("TOOK_TORCH"), "accepting sets TOOK_TORCH");
}
#[test]
fn stairs_change_player_elevation() {
let tmp = TestDir::new("stairs");
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"));
fs::write(
root.join("data/maps/Town/map.tmx.json"),
r#"{
"width": 4, "height": 3, "tilewidth": 16, "tileheight": 16,
"layers": [
{ "name": "ground", "width": 4, "height": 3, "data": [1,1,1,1, 1,1,1,1, 1,1,1,1] },
{ "name": "collision", "width": 4, "height": 3, "data": [1,1,1,1, 0,0,0,1, 1,1,1,1] },
{ "name": "collision1", "width": 4, "height": 3, "data": [1,1,1,1, 0,0,0,1, 1,1,1,1] },
{ "name": "stairs", "width": 4, "height": 3, "data": [0,0,0,0, 1,2,0,0, 0,0,0,0] }
],
"tilesets": [
{ "firstgid": 1, "name": "demo", "tilewidth": 16, "tileheight": 16, "tilecount": 4 }
]
}"#,
)
.unwrap();
fs::write(root.join("data/maps/Town/objects.json"), "{}").unwrap();
let project = LoadedProject::load(&root).expect("load demo project");
let mut game = RunnerGame::new(project, RunnerOptions::default()).expect("boot game");
dismiss_dialogue(&mut game); game.debug_place(1, 1, Direction::Down);
assert_eq!(game.player_elevation(), 0, "spawns on the ground level");
for _ in 0..8 {
frame(&mut game, GbButton::Left.bit_mask());
}
assert_eq!(game.player_tile(), (0, 1));
assert_eq!(game.player_elevation(), 1, "GID 1 ascends one level");
for _ in 0..8 {
frame(&mut game, GbButton::Right.bit_mask());
}
assert_eq!(game.player_tile(), (1, 1));
assert_eq!(game.player_elevation(), 0, "GID 2 descends one level");
}
#[test]
fn facing_a_sign_and_pressing_a_reads_it() {
let (_tmp, mut game) = boot("sign");
dismiss_dialogue(&mut game);
game.debug_place(1, 2, Direction::Up);
press_a(&mut game);
let page = game.dialogue_text().expect("sign text shows");
assert!(page.contains("You are standing in Town"), "page: {page:?}");
dismiss_dialogue(&mut game);
assert!(game.dialogue_text().is_none());
game.debug_place(3, 3, Direction::Up);
press_a(&mut game);
assert!(game.dialogue_text().is_none());
}
#[test]
fn mapless_project_boots_dialogue_only() {
let tmp = TestDir::new("mapless");
let root = tmp.path().join("demo");
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/demo");
copy_dir(&fixture, &root);
fs::remove_dir_all(root.join("data/maps")).unwrap();
let project = LoadedProject::load(&root).expect("load");
let mut game = RunnerGame::new(project, RunnerOptions::default()).expect("boot");
assert_eq!(game.current_map_id(), None);
let page = game.dialogue_text().expect("entry scene text");
assert!(page.contains("Welcome to the demo project"), "page: {page:?}");
dismiss_dialogue(&mut game);
idle(&mut game, 30);
let mut fb = FrameBuffer::new(RenderConfig::new(320, 240), Rgba::BLACK);
game.draw(&mut fb);
idle(&mut game, 30);
let mut fb = FrameBuffer::new(RenderConfig::new(320, 240), Rgba::BLACK);
game.draw(&mut fb);
let colors: HashSet<[u8; 4]> = fb
.data
.chunks_exact(4)
.map(|px| [px[0], px[1], px[2], px[3]])
.collect();
assert!(colors.len() > 2, "end card should render text, got {colors:?}");
}