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>
399 lines
14 KiB
Rust
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"
|
|
);
|
|
}
|