diff --git a/dungeons/README.md b/dungeons/README.md index c0a8c90..47588af 100644 --- a/dungeons/README.md +++ b/dungeons/README.md @@ -33,3 +33,34 @@ module, so keep item names dungeon-prefixed. Entries and positions are dump-verified against classic-db z2815. Omitted: Smite's stomp stun (no vanilla data row backs it). + +Door and encounter rows persist in the Module across a Standalone restart. A restart preserves +open doors for completed encounters and closed doors for unfinished encounters. + +## Verification + +Install this Package in the Core checkout, then run its durable test: + +```sh +python3 dungeons/testsuite/run.py /path/to/LyraCore test --locked \ + --test deadmines_doors -- --ignored --test-threads=1 +``` + +The test uses a private persistent Standalone. It kills Rhahk'Zor and the Shredder, fires the +Defias Cannon through the production use path, and restarts the node. The Factory and Iron Clad +Doors must remain open; Sneed's and Gilnid's doors must remain closed. It then kills Sneed and +Gilnid and restarts again. All four doors and their completed encounter states must persist. +Core supplies the private fixture through `lyracore-test-support`; the runner selects Core and +keeps build output outside the Package. + +The stage reducers in `src/deadmines_verify.rs` take no arguments because fixture GUIDs exceed +the CLI's safe integer range. After setup, each stage resolves its subjects in the Module, calls +the production kill, damage, or gameobject use path, and checks the durable outcome in the same +transaction. +Core's `module/tests/deadmines_package.rs` also calls these stages to check the boss deaths, +cannon, and Smite's weapon changes: + +```sh +cd /path/to/LyraCore +cargo test -p lyracore-module --test deadmines_package -- --ignored --test-threads=1 +``` diff --git a/dungeons/src/deadmines.rs b/dungeons/src/deadmines.rs index 00435c8..9b84e04 100644 --- a/dungeons/src/deadmines.rs +++ b/dungeons/src/deadmines.rs @@ -1,21 +1,4 @@ -//! Deadmines (map 36) choreography — the encounter kernel's first consumer. -//! -//! Unlike the retired EventAI-routed dungeons, the vanilla data carries NO -//! `ACTION_T_SET_INSTANCE_DATA` rows for any Deadmines creature (dump-verified against classic-db -//! z2815: their ACID rows are combat spells and yells only, which import generically — VanCleef's -//! yells and his 50%-HP allies summon need nothing from this Package). The door, cannon, and -//! Mr. Smite behavior lived in a C++ instance script upstream, so this Package rebuilds it on the -//! kernel's notify hooks and primitives: -//! -//! - `on_creature_death` — Rhahk'Zor, Sneed, and Gilnid each open their door; Sneed's Shredder -//! ejects Sneed on destruction. -//! - `on_go_used` — firing the Defias Cannon breaches the Iron Clad Door. -//! - `on_hp_threshold` + `equip_swap` + `move_to_point` — Mr. Smite's stair scene: at 66% he -//! runs to his chest and comes back dual-wielding, at 33% he switches to his two-hand hammer. -//! His yells are this Package's own content; the source data has no text rows for them. -//! -//! Omitted from the scene: Smite's stomp stun (no vanilla data row backs it; revisit if a -//! curated spell mapping lands). +//! Read dungeons/README.md#deadmines before changing encounter behavior. use spacetimedb::ReducerContext; @@ -187,7 +170,9 @@ fn eject_sneed(ctx: &ReducerContext, shredder_guid: u64, instance_id: u64) { fn open_door_logged(ctx: &ReducerContext, go_entry: u32, instance_id: u64) { if let Err(error) = encounter::open_door(ctx, go_entry, instance_id) { - spacetimedb::log::warn!("deadmines: door {go_entry} did not open: {error}"); + spacetimedb::log::warn!( + "deadmines: door {go_entry} in instance {instance_id} did not open: {error}" + ); } } diff --git a/dungeons/src/deadmines_verify.rs b/dungeons/src/deadmines_verify.rs index 6612c58..f92e576 100644 --- a/dungeons/src/deadmines_verify.rs +++ b/dungeons/src/deadmines_verify.rs @@ -1,10 +1,6 @@ #![cfg(feature = "debug_reducers")] -//! Deadmines verification harness (`debug_reducers` only): stage reducers the durable test -//! (`module/tests/deadmines_package.rs`) calls in order over the standalone CLI. Every reducer is -//! argument-free — fixture guids exceed the CLI's safe integer range, so each stage resolves its -//! own subjects server-side, drives the REAL production path (kill, damage, gameobject use), and -//! verifies the durable outcome in the same call. +//! Read dungeons/README.md#verification before running these fixture reducers. use spacetimedb::{reducer, ReducerContext, Table}; @@ -25,10 +21,10 @@ const FIXTURE_LOW_BAND: u64 = 0x10_0000; const DEADMINES_INSTANCE: u64 = 936; const GO_FIXTURE_BAND: u64 = 0xF11D << 48; -/// Seed a Deadmines fixture instance: doors, cannon, chest, Smite's weapons, the bosses, and one -/// player. Positions come from the classic-db z2815 spawn rows. +/// Requires a Shard without imported content; positions follow classic-db z2815. #[reducer] pub fn debug_deadmines_begin(ctx: &ReducerContext) -> Result<(), String> { + crate::package::fixture::require_no_imported_content(ctx)?; install_instance(ctx, MAP_ID, DEADMINES_INSTANCE)?; for (entry, type_id, display_id, x, y, z, orientation) in [ ( @@ -54,7 +50,7 @@ pub fn debug_deadmines_begin(ctx: &ReducerContext) -> Result<(), String> { ), (SMITES_CHEST, 5, 259, 2.69086, -781.633, 9.76985, 2.33874), ] { - seed_gameobject(ctx, entry, type_id, display_id, x, y, z, orientation)?; + seed_gameobject(ctx, entry, type_id, display_id, [x, y, z, orientation])?; } seed_smite_weapon(ctx, SMITES_REAVER, "Smite's Reaver", 13913)?; seed_smite_weapon(ctx, SMITES_MIGHTY_HAMMER, "Smite's Mighty Hammer", 19610)?; @@ -71,8 +67,7 @@ pub fn debug_deadmines_begin(ctx: &ReducerContext) -> Result<(), String> { let player = spawn_fixture_player(ctx, MAP_ID, DEADMINES_INSTANCE, 44)?; set_fixture_position(ctx, player, -5.0, -760.0, 9.8)?; - // The real fixture registers these through the Package's `on_creature_spawn` hook when Smite - // spawns via the production path; `spawn_source` writes the row directly, so arm them here. + // Direct fixture inserts do not fire on_creature_spawn. encounter::watch_hp_threshold(ctx, MR_SMITE, SMITE_FIRST_STAND_PCT)?; encounter::watch_hp_threshold(ctx, MR_SMITE, SMITE_SECOND_STAND_PCT)?; Ok(()) @@ -146,12 +141,11 @@ pub fn debug_deadmines_gilnid_falls(ctx: &ReducerContext) -> Result<(), String> require_done(ctx, ENCOUNTER_GILNID, "Gilnid encounter state is not Done") } -/// Damage Smite through his 66% threshold: yell, chest run, dual Reavers. #[reducer] pub fn debug_deadmines_smite_improvises(ctx: &ReducerContext) -> Result<(), String> { damage_smite_to_pct(ctx, 60)?; require_yell_containing(ctx, "improvise")?; - require_smite_at_chest(ctx)?; + require_smite_chest_run(ctx)?; require_smite_equip(ctx, SMITES_REAVER, SMITES_REAVER, "66%") } @@ -189,20 +183,14 @@ pub fn debug_deadmines_smite_falls(ctx: &ReducerContext) -> Result<(), String> { ) } -// ------------------------------------------------------------------------------------------- -// Fixture plumbing -// ------------------------------------------------------------------------------------------- - fn seed_gameobject( ctx: &ReducerContext, entry: u32, type_id: u8, display_id: u32, - x: f32, - y: f32, - z: f32, - orientation: f32, + position: [f32; 4], ) -> Result<(), String> { + let [x, y, z, orientation] = position; let templates = ctx.db.game_gameobject_template(); if templates.entry().find(entry).is_none() { templates.insert(crate::tables::GameObjectTemplate { @@ -342,31 +330,29 @@ fn require_yell_containing(ctx: &ReducerContext, fragment: &str) -> Result<(), S ) } -fn require_smite_at_chest(ctx: &ReducerContext) -> Result<(), String> { +fn require_smite_chest_run(ctx: &ReducerContext) -> Result<(), String> { let smite_guid = fixture_guid(MR_SMITE, 43); - let smite = ctx + let leg = ctx .db - .game_world_entity() + .game_creature_spline() .guid() .find(smite_guid) - .ok_or_else(|| "Mr. Smite disappeared".to_string())?; + .ok_or_else(|| "Smite's chest run emitted no movement spline".to_string())?; let chest = ctx .db .game_gameobject() .guid() .find(GO_FIXTURE_BAND | u64::from(SMITES_CHEST)) .ok_or_else(|| "Smite's Chest disappeared".to_string())?; + // Movement updates the stored position on later ticks. require( - (smite.x - chest.x).abs() < 0.5 && (smite.y - chest.y).abs() < 0.5, - "Smite did not run to his chest", - )?; - require( - ctx.db - .game_creature_spline() - .guid() - .find(smite_guid) - .is_some(), - "Smite's chest run emitted no movement spline", + leg.map_id == chest.map_id + && leg.instance_id == chest.instance_id + && leg.run + && (leg.dx - chest.x).abs() < 0.5 + && (leg.dy - chest.y).abs() < 0.5 + && (leg.dz - chest.z).abs() < 0.5, + "Smite's run does not lead to his chest", ) } diff --git a/dungeons/testsuite/Cargo.lock b/dungeons/testsuite/Cargo.lock new file mode 100644 index 0000000..fd426d5 --- /dev/null +++ b/dungeons/testsuite/Cargo.lock @@ -0,0 +1,128 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "lyracore-dungeons-tests" +version = "0.1.0" +dependencies = [ + "lyracore-test-support", +] + +[[package]] +name = "lyracore-test-support" +version = "0.1.0" +dependencies = [ + "rustc_lexer", + "serde_json", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustc_lexer" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c86aae0c77166108c01305ee1a36a1e77289d7dc6ca0a3cd91ff4992de2d16a5" +dependencies = [ + "unicode-xid", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/dungeons/testsuite/Cargo.toml b/dungeons/testsuite/Cargo.toml new file mode 100644 index 0000000..e4c9615 --- /dev/null +++ b/dungeons/testsuite/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "lyracore-dungeons-tests" +version = "0.1.0" +edition = "2021" +rust-version = "1.93.0" +publish = false + +[workspace] + +[dev-dependencies] +lyracore-test-support = "0.1.0" diff --git a/dungeons/testsuite/run.py b/dungeons/testsuite/run.py new file mode 100644 index 0000000..68d5ee6 --- /dev/null +++ b/dungeons/testsuite/run.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""Read dungeons/README.md#verification before running against a Core checkout.""" + +import json +import os +from pathlib import Path +import subprocess +import sys +import tomllib + + +def main() -> int: + if len(sys.argv) < 3: + raise SystemExit("usage: run.py CORE COMMAND [CARGO-ARGS...]") + core = Path(sys.argv[1]).resolve(strict=True) + command, *arguments = sys.argv[2:] + suite = Path(__file__).resolve().parent + installed = core / "packages/dungeons" + if not installed.is_dir() or installed.resolve() != suite.parent: + raise SystemExit("install this dungeons Package in the selected Core checkout first") + if command not in {"test", "check", "clippy", "fmt", "generate-lockfile"}: + raise SystemExit(f"unsupported Cargo command: {command}") + toolchain = tomllib.loads((core / "rust-toolchain.toml").read_text())["toolchain"]["channel"] + support = core / "crates/lyracore-test-support" + if not (support / "Cargo.toml").is_file(): + raise SystemExit(f"Core does not provide lyracore-test-support: {support}") + environment = os.environ.copy() + environment["LYRACORE_TEST_CORE"] = str(core) + environment.setdefault("CARGO_TARGET_DIR", str(core / "target")) + patch = ["--config", f"patch.crates-io.lyracore-test-support.path={json.dumps(str(support))}"] + cargo = ["cargo", f"+{toolchain}"] + cargo += [*patch, command] if command == "fmt" else [command, *patch] + cargo += ["--manifest-path", str(suite / "Cargo.toml"), *arguments] + return subprocess.run(cargo, cwd=core, env=environment).returncode + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/dungeons/testsuite/tests/deadmines_doors.rs b/dungeons/testsuite/tests/deadmines_doors.rs new file mode 100644 index 0000000..ff87166 --- /dev/null +++ b/dungeons/testsuite/tests/deadmines_doors.rs @@ -0,0 +1,81 @@ +use lyracore_test_support::Standalone; + +fn doors(node: &Standalone) -> Vec<(u32, u8)> { + let mut doors: Vec<_> = node + .query_rows( + "SELECT template_entry, state FROM game_gameobject WHERE instance_id = 936 AND map_id = 36", + ) + .into_iter() + .filter_map(|row| { + let entry = row["template_entry"].parse::().unwrap(); + [13965, 16397, 16399, 16400] + .contains(&entry) + .then(|| (entry, row["state"].parse::().unwrap())) + }) + .collect(); + doors.sort_unstable(); + doors +} + +fn door_encounters(node: &Standalone) -> Vec<(u32, u8)> { + let mut states: Vec<_> = node + .query_rows("SELECT encounter_id, state FROM game_encounter_state WHERE instance_id = 936") + .into_iter() + .filter_map(|row| { + let encounter_id = row["encounter_id"].parse::().unwrap(); + [0, 1, 2, 4] + .contains(&encounter_id) + .then(|| (encounter_id, row["state"].parse::().unwrap())) + }) + .collect(); + states.sort_unstable(); + states +} + +#[test] +#[ignore = "requires the pinned Standalone, Module Wasm, and this dungeons Package installed"] +fn deadmines_doors_and_encounter_completion_survive_restart() { + let mut node = Standalone::start_persistent("deadmines-door-persistence"); + node.publish_module(); + node.assert_call("debug_deadmines_begin", &[]); + assert_eq!( + doors(&node), + [(13965, 0), (16397, 0), (16399, 0), (16400, 0)] + ); + assert!(door_encounters(&node).is_empty()); + + for stage in [ + "debug_deadmines_rhahkzor_falls", + "debug_deadmines_shredder_ejects_sneed", + "debug_deadmines_cannon_breaches", + ] { + node.assert_call(stage, &[]); + } + assert_eq!( + doors(&node), + [(13965, 1), (16397, 1), (16399, 0), (16400, 0)] + ); + assert_eq!(door_encounters(&node), [(0, 2), (4, 2)]); + + node.restart_persistent(); + assert_eq!( + doors(&node), + [(13965, 1), (16397, 1), (16399, 0), (16400, 0)] + ); + assert_eq!(door_encounters(&node), [(0, 2), (4, 2)]); + + node.assert_call("debug_deadmines_sneed_falls", &[]); + node.assert_call("debug_deadmines_gilnid_falls", &[]); + assert_eq!( + doors(&node), + [(13965, 1), (16397, 1), (16399, 1), (16400, 1)] + ); + assert_eq!(door_encounters(&node), [(0, 2), (1, 2), (2, 2), (4, 2)]); + + node.restart_persistent(); + assert_eq!( + doors(&node), + [(13965, 1), (16397, 1), (16399, 1), (16400, 1)] + ); + assert_eq!(door_encounters(&node), [(0, 2), (1, 2), (2, 2), (4, 2)]); +}