reikhelm/game-wasm/tests/run.rs
Parley Hatch 5813ee3b5a feat(game): REIKHELM: DESCENT — playable first-person roguelike
The pieces finally meet: procgen dungeons + the baked EoB atlas pipeline +
the Vigor combat engine become one game. Three fixed pre-baked floors
(seeds 7 → 69 → 59, 208 frames), monsters/loot/encounters rerolled per run
and composited over the empty scenes; the Ember Sovereign waits at the
bottom.

- game-wasm: run state machine in Rust (manifest nav graph as dungeon
  truth, seeded spawning by theme/depth, embedded combat-core Encounter,
  vigor/fatigue persistence between fights, rest/potions/relics/flee,
  permadeath + victory). 8 native tests incl. a whole-run bot and a
  ScriptedPlayer boss-winnability probe used for tuning.
- game-web: no-framework front-end — title/death/victory screens, baked
  frames with screen-blend monster portraits, combat overlay (dual vigor
  bars, cast telegraphs, cooldown sweeps, floaters), minimap with fog,
  all SFX synthesized in WebAudio.
- balance: floor-scaled levels with a hero's edge over trash, no packs on
  floor 1, boss tuned by sim sweep (~35%/55% win at skill 50/80).
- campaign atlases now versioned (tools/.gitignore exception); boss
  portrait added to the Z-Image roster.

Verified end-to-end in-browser: an autopilot beat the full game through
the real UI (10 fights, 2 flees, Sovereign down), and died plenty before
that.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 23:30:50 -06:00

399 lines
14 KiB
Rust

//! 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<String> = 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<String> {
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<Vec<usize>> {
let by_id = run.floor().manifest.by_id();
let mut prev: HashMap<usize, usize> = 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<Box<dyn Controller>> =
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"
);
}