//! Integration tests for the run state machine, driven against the REAL baked //! campaign: game-web/config.json + the three atlas manifests under //! tools/out/atlas/. A scripted bot plays whole runs headlessly — the same //! loop the browser drives, minus the pixels. use game_wasm::manifest::{Dir, DIRS}; use game_wasm::run::{GameRun, Mode}; use std::collections::{HashMap, VecDeque}; use std::fs; use std::path::Path; const CAMPAIGN: [&str; 3] = ["7", "69", "59"]; fn repo() -> &'static Path { Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap() } fn config_json() -> String { fs::read_to_string(repo().join("game-web/config.json")).expect("game-web/config.json") } fn manifests_json() -> String { let parts: Vec = CAMPAIGN .iter() .map(|s| { fs::read_to_string(repo().join(format!("tools/out/atlas/{s}/manifest.json"))) .unwrap_or_else(|_| panic!("manifest for seed {s} — bake the campaign first")) }) .collect(); format!("[{}]", parts.join(",")) } fn new_run(archetype: &str, seed: u64) -> GameRun { GameRun::new(&config_json(), &manifests_json(), archetype, seed).expect("run builds") } // ------------------------------------------------------------------ // // Placement invariants // ------------------------------------------------------------------ // #[test] fn placement_invariants_across_seeds() { for seed in [1u64, 2, 3, 42, 1337, 9001] { let run = new_run("duelist", seed); assert_eq!(run.floors.len(), 3); for (i, floor) in run.floors.iter().enumerate() { let start = floor.manifest.start_room; assert!( !floor.monsters.contains_key(&start), "start room must be safe" ); assert!( !floor.treasure.contains_key(&start), "no loot at the entrance" ); assert_ne!(floor.exit_room, start, "exit is somewhere deeper"); assert!( floor .manifest .distances(start) .contains_key(&floor.exit_room), "exit reachable from start" ); let fights = floor.monsters.len(); assert!( (3..=14).contains(&fights), "floor {i} seed {seed}: {fights} fights is out of the sane band" ); if i == 2 { let boss = &floor.monsters[&floor.exit_room]; assert_eq!(boss.len(), 1); assert_eq!( boss[0].archetype, "boss", "the deepest room belongs to the boss" ); } else { assert!(floor .monsters .get(&floor.exit_room) .map(|p| p.iter().all(|s| s.archetype != "boss")) .unwrap_or(true)); } } } } #[test] fn dir_arithmetic() { assert_eq!(Dir::N.turned(1), Dir::E); assert_eq!(Dir::N.turned(-1), Dir::W); assert_eq!(Dir::S.opposite(), Dir::N); assert_eq!(Dir::W.turned(4), Dir::W); } // ------------------------------------------------------------------ // // Determinism // ------------------------------------------------------------------ // #[test] fn identical_runs_emit_identical_state() { let script = |run: &mut GameRun| -> Vec { let mut out = vec![run.state_json()]; for _ in 0..6 { run.step(true); out.push(run.state_json()); // Tick any battle to a fixed depth so combat state is compared too. for _ in 0..40 { run.combat_tick(); } out.push(run.state_json()); run.turn(1); } out }; let a = script(&mut new_run("duelist", 777)); let b = script(&mut new_run("duelist", 777)); assert_eq!( a, b, "same seed + same commands must replay bit-identically" ); } // ------------------------------------------------------------------ // // Combat handoff + persistence // ------------------------------------------------------------------ // /// March the player into the first monster room (BFS), returns rooms walked. fn walk_into_trouble(run: &mut GameRun) -> bool { for _ in 0..64 { if run.mode == Mode::Combat { return true; } let target = { let floor = run.floor(); nearest(run, |r| floor.monsters.contains_key(&r)) }; let Some(path) = target else { return false }; step_along(run, &path); } run.mode == Mode::Combat } /// BFS path from the current room to the nearest room satisfying `want`. fn nearest(run: &GameRun, want: impl Fn(usize) -> bool) -> Option> { let by_id = run.floor().manifest.by_id(); let mut prev: HashMap = HashMap::new(); let mut q = VecDeque::from([run.room]); let mut seen = std::collections::HashSet::from([run.room]); while let Some(at) = q.pop_front() { if at != run.room && want(at) { let mut path = vec![at]; let mut cur = at; while let Some(&p) = prev.get(&cur) { if p == run.room { break; } path.push(p); cur = p; } path.reverse(); return Some(path); } if let Some(room) = by_id.get(&at) { for d in DIRS { if let Some(n) = room.nav.get(d) { if seen.insert(n) { prev.insert(n, at); q.push_back(n); } } } } } None } /// Walk the first hop of `path` (sets facing directly; turn() is unit-tested). fn step_along(run: &mut GameRun, path: &[usize]) { let Some(&next) = path.first() else { return }; let dir = { let by_id = run.floor().manifest.by_id(); DIRS.into_iter() .find(|d| by_id[&run.room].nav.get(*d) == Some(next)) }; if let Some(dir) = dir { run.facing = dir; run.step(true); } } /// Fight the current battle to resolution with a simple but active policy: /// heavies on cooldown cadence, second wind when the ceiling sags. fn fight_out(run: &mut GameRun) { let mut t = 0u64; while run.mode == Mode::Combat && t < 30_000 { if t % 50 == 5 { run.use_ability("heavy_strike", -1); } if t % 80 == 30 { run.use_ability("second_wind", -1); } run.combat_tick(); t += 1; } assert!(t < 30_000, "fight failed to resolve in 30k ticks"); } #[test] fn fights_wound_and_wounds_persist() { let mut run = new_run("duelist", 4242); assert!( walk_into_trouble(&mut run), "campaign floor 1 must offer a fight" ); fight_out(&mut run); let _ = run.state_json(); // settles the finished battle if run.mode == Mode::Explore { // Swinging costs fatigue; winning a fight always leaves a mark. assert!( run.player.fatigue_bp > 0 || run.player.vigor_bp < 10_000, "a won fight must leave the player worn" ); assert!(run.stats.kills > 0); } else { assert_eq!(run.mode, Mode::Dead, "only other resolution is death"); } } #[test] fn fleeing_returns_and_costs_ceiling() { let mut run = new_run("duelist", 31337); assert!(walk_into_trouble(&mut run)); let battle_room = run.room; let before = run.prev_room; for _ in 0..20 { run.combat_tick(); } run.flee(); assert_eq!(run.mode, Mode::Explore); assert_eq!(run.room, before, "flee backs out the way you came"); assert!(run.player.fatigue_bp >= 800, "flee marks the ceiling"); assert!( run.floor().monsters.contains_key(&battle_room), "the monsters don't forget" ); } #[test] fn potions_apply_out_of_combat_only() { let mut run = new_run("duelist", 99); run.player.draughts = 1; run.player.tonics = 1; run.player.vigor_bp = 4_000; run.player.fatigue_bp = 3_000; run.use_item("draught"); assert_eq!(run.player.vigor_bp, 7_000 - 0, "draught restores fill"); run.use_item("tonic"); assert_eq!(run.player.fatigue_bp, 0, "tonic clears fatigue"); assert_eq!(run.player.draughts, 0); assert_eq!(run.player.tonics, 0); run.use_item("draught"); let v = run.player.vigor_bp; assert_eq!(v, 7_000, "empty belt does nothing"); } // ------------------------------------------------------------------ // // Boss tuning probe — pure combat-core sim, no dungeon. // ------------------------------------------------------------------ // #[test] fn boss_is_winnable_by_skilled_play() { use combat_core::actor::compile_actor; use combat_core::config::CombatConfig; use combat_core::controller::{Controller, ScriptedPlayer}; use combat_core::engine::{Encounter, EncounterOptions}; use combat_core::event::Outcome; use game_wasm::monster_ai::MonsterAI; let config: CombatConfig = serde_json::from_str(&config_json()).unwrap(); let rules = config.compile(); let mut report = Vec::new(); let mut best = 0; for skill in [50i64, 80, 100] { let mut wins = 0; let trials = 40; for seed in 0..trials { // The hero as they'd plausibly reach the bottom: level 12 (two // descents), a couple of relics, rested but not pristine. let mut hero = config.stat_blocks["duelist"].clone(); hero.team = 0; hero.level = 12; hero.max_vigor = 180; hero.start_vigor_bp = 9_500; hero.start_fatigue_bp = 500; let mut boss = config.stat_blocks["boss"].clone(); boss.team = 1; let (hl, _) = rules.resolve_loadout(&hero.loadout); let (bl, _) = rules.resolve_loadout(&boss.loadout); let actors = vec![ compile_actor(0, &hero, hl, rules.tick_rate), compile_actor(1, &boss, bl, rules.tick_rate), ]; let controllers: Vec> = vec![Box::new(ScriptedPlayer::new(skill)), Box::new(MonsterAI)]; let opts = EncounterOptions { max_ticks: 6_000, log_events: false, sample_every: 0, }; let mut enc = Encounter::new(actors, controllers, rules.clone(), seed, opts); if matches!(enc.run().outcome, Outcome::Win { team: 0 }) { wins += 1; } } best = best.max(wins); report.push(format!("skill {skill}: {wins}/{trials}")); } println!("boss win rates — {}", report.join(", ")); assert!( best >= 8, "even perfect play rarely beats the boss: {}", report.join(", ") ); } // ------------------------------------------------------------------ // // Whole-run bot // ------------------------------------------------------------------ // #[test] fn bot_plays_whole_runs_to_a_verdict() { let mut verdicts = Vec::new(); for seed in [11u64, 22, 33, 44, 55, 66] { let mut run = new_run("duelist", seed); let mut budget = 4_000usize; loop { budget -= 1; assert!(budget > 0, "seed {seed}: run did not terminate in budget"); match run.mode { Mode::Dead | Mode::Victory => break, Mode::Combat => fight_out(&mut run), Mode::Explore => { let _ = run.state_json(); // A patient crawler rests up before opening the next door. if run.player.vigor_bp < 9_000 { run.rest(); continue; } if run.player.fatigue_bp > 4_000 && run.player.tonics > 0 { run.use_item("tonic"); } if run.player.vigor_bp < 4_500 && run.player.draughts > 0 { run.use_item("draught"); } let at_exit = run.room == run.floor().exit_room; let last = run.floor_idx + 1 == run.floors.len(); if at_exit && !last { run.descend(); continue; } let goal = { let floor = run.floor(); let exit = floor.exit_room; nearest(&run, |r| { floor.treasure.contains_key(&r) || floor.monsters.contains_key(&r) || r == exit }) }; match goal { Some(path) => step_along(&mut run, &path), None => break, // nothing left and no exit path — shouldn't happen } } } } let v = format!( "seed {seed}: {} on floor {} — kills {}, gold {}, relics {}, fled {}, explored {}", run.mode.label(), run.floor_idx + 1, run.stats.kills, run.stats.gold_looted, run.player.relics, run.stats.flees, run.stats.rooms_explored, ); println!("{v}"); verdicts.push((run.mode, run.floor_idx)); assert!(matches!(run.mode, Mode::Dead | Mode::Victory)); } // The campaign must be survivable past floor 1 for a mediocre bot, or the // tuning is off for humans too. assert!( verdicts.iter().any(|(_, floor)| *floor >= 1), "no bot run escaped floor 1 — too brutal" ); }