From 1e98c95e2328c8fd2320fdb6f8e86b776c2ae3fe Mon Sep 17 00:00:00 2001 From: Mathias Hartveit Date: Thu, 15 Oct 2026 13:48:33 +0200 Subject: [PATCH 1/2] fix(dungeons): restore boss doors from saved completion --- dungeons/README.md | 25 ++++ dungeons/src/deadmines.rs | 50 ++++---- dungeons/src/deadmines_verify.rs | 132 +++++++++++++++----- dungeons/testsuite/Cargo.lock | 128 +++++++++++++++++++ dungeons/testsuite/Cargo.toml | 11 ++ dungeons/testsuite/run.py | 38 ++++++ dungeons/testsuite/tests/deadmines_doors.rs | 74 +++++++++++ 7 files changed, 403 insertions(+), 55 deletions(-) create mode 100644 dungeons/testsuite/Cargo.lock create mode 100644 dungeons/testsuite/Cargo.toml create mode 100644 dungeons/testsuite/run.py create mode 100644 dungeons/testsuite/tests/deadmines_doors.rs diff --git a/dungeons/README.md b/dungeons/README.md index c0a8c90..982257f 100644 --- a/dungeons/README.md +++ b/dungeons/README.md @@ -28,8 +28,33 @@ module, so keep item names dungeon-prefixed. ## Deadmines - Rhahk'Zor, Sneed, and Gilnid each open their door on death; Sneed's Shredder ejects Sneed. + If a completed encounter's door is recreated closed, the next creature tick opens it from saved + completion. A reset, unfinished encounter, or another instance keeps its own door state. - Firing the Defias Cannon breaches the Iron Clad Door. - Mr. Smite rearms at his chest at 66% and 33% health: dual Reavers, then his two-hand hammer. Entries and positions are dump-verified against classic-db z2815. Omitted: Smite's stomp stun (no vanilla data row backs it). + +The restored boss doors follow the load behavior in +[CMaNGOS's pinned instance script](https://github.com/cmangos/mangos-classic/blob/ca0775fe352fb67a0e82dc6051f0563187d569a5/src/game/AI/ScriptDevAI/scripts/eastern_kingdoms/deadmines/instance_deadmines.cpp#L81-L98), +also represented by +[OMaNGOS's pinned Deadmines script](https://tangled.org/carl.tngl.sh/OMaNGOS/blob/d3f8c589aeb06d8a878050a4a11256479011a031/crates/content/src/eastern_kingdoms/deadmines.rs). +The Package checks live gameobjects indexed to map 36. Core has no gameobject-created hook, so +the Package uses its existing tick marker and calls `open_door` only for a closed boss door whose +own encounter is Done. It does not replay the boss death. + +## 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 completes all three boss encounters through the +kill path, deletes their door rows, restarts the node, and recreates closed doors twice. Each door +must reopen without another boss death. Closed copies in reset, InProgress, and Failed encounters, +another map, and instance 0 must stay closed. Core supplies the private fixture through +`lyracore-test-support`; the runner selects Core and keeps build output outside the Package. diff --git a/dungeons/src/deadmines.rs b/dungeons/src/deadmines.rs index 00435c8..1a3433b 100644 --- a/dungeons/src/deadmines.rs +++ b/dungeons/src/deadmines.rs @@ -1,26 +1,9 @@ -//! 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 or recovery. use spacetimedb::ReducerContext; -use crate::package::encounter::{self, ENCOUNTER_DONE}; -use crate::tables::{game_gameobject, game_world_entity}; +use crate::package::encounter::{self, DOOR_OPEN_STATE, ENCOUNTER_DONE}; +use crate::tables::{game_gameobject, game_instance, game_world_entity}; pub(crate) const MAP_ID: u32 = 36; @@ -112,6 +95,29 @@ crate::game_hook!(on_creature_death, fn deadmines_boss_died(ctx, payload) { } }); +crate::game_tick_pass!(fn deadmines_restore_completed_boss_doors(ctx) { + // Core has no gameobject-created hook; recovery runs on the creature tick. + for door in ctx.db.game_gameobject().by_map().filter(&MAP_ID) { + if door.instance_id == 0 || door.state == DOOR_OPEN_STATE { + continue; + } + let encounter_id = match door.template_entry { + FACTORY_DOOR => ENCOUNTER_RHAHKZOR, + MAST_ROOM_DOOR => ENCOUNTER_SNEED, + FOUNDRY_DOOR => ENCOUNTER_GILNID, + _ => continue, + }; + if ctx.db.game_instance().instance_id().find(door.instance_id) + .is_none_or(|instance| instance.map_id != MAP_ID) + { + continue; + } + if encounter::get_encounter_state(ctx, door.instance_id, encounter_id) == ENCOUNTER_DONE { + open_door_logged(ctx, door.template_entry, door.instance_id); + } + } +}); + crate::game_hook!(on_go_used, fn defias_cannon_fired(ctx, payload) { if payload.go_entry != DEFIAS_CANNON || payload.instance_id == 0 { return; @@ -187,7 +193,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..b180022 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}; @@ -14,7 +10,9 @@ use super::deadmines::{ MR_SMITE, RHAHKZOR, SMITES_CHEST, SMITES_MIGHTY_HAMMER, SMITES_REAVER, SMITE_FIRST_STAND_PCT, SMITE_SECOND_STAND_PCT, SNEED, SNEEDS_SHREDDER, }; -use crate::package::encounter::{self, DOOR_OPEN_STATE, ENCOUNTER_DONE}; +use crate::package::encounter::{ + self, DOOR_OPEN_STATE, ENCOUNTER_DONE, ENCOUNTER_FAILED, ENCOUNTER_IN_PROGRESS, +}; use crate::tables::{ game_chat_event, game_creature_spline, game_creature_template, game_encounter_equip, game_gameobject, game_gameobject_template, game_instance, game_item_template, @@ -24,11 +22,12 @@ use crate::tables::{ const FIXTURE_LOW_BAND: u64 = 0x10_0000; const DEADMINES_INSTANCE: u64 = 936; const GO_FIXTURE_BAND: u64 = 0xF11D << 48; +const DOOR_RELOAD_COPY_BAND: u64 = GO_FIXTURE_BAND | (1 << 40); -/// 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 +53,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 +70,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 +144,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 +186,89 @@ pub fn debug_deadmines_smite_falls(ctx: &ReducerContext) -> Result<(), String> { ) } -// ------------------------------------------------------------------------------------------- -// Fixture plumbing -// ------------------------------------------------------------------------------------------- +/// Requires all three boss encounters Done in fixture instance 936. +#[reducer] +pub fn debug_deadmines_prepare_door_reload(ctx: &ReducerContext) -> Result<(), String> { + crate::package::fixture::require_no_imported_content(ctx)?; + install_instance(ctx, MAP_ID, DEADMINES_INSTANCE + 1)?; + install_instance(ctx, 1, DEADMINES_INSTANCE + 2)?; + for (entry, encounter_id) in [ + (FACTORY_DOOR, ENCOUNTER_RHAHKZOR), + (MAST_ROOM_DOOR, ENCOUNTER_SNEED), + (FOUNDRY_DOOR, ENCOUNTER_GILNID), + ] { + require_done( + ctx, + encounter_id, + "boss completion is missing before reload", + )?; + encounter::set_encounter_state(ctx, DEADMINES_INSTANCE + 1, encounter_id, ENCOUNTER_DONE)?; + if entry == FACTORY_DOOR { + encounter::encounter_reset(ctx, DEADMINES_INSTANCE + 1, encounter_id); + } else { + encounter::set_encounter_state( + ctx, + DEADMINES_INSTANCE + 1, + encounter_id, + if entry == MAST_ROOM_DOOR { + ENCOUNTER_IN_PROGRESS + } else { + ENCOUNTER_FAILED + }, + )?; + } + encounter::set_encounter_state(ctx, DEADMINES_INSTANCE + 2, encounter_id, ENCOUNTER_DONE)?; + let doors = ctx.db.game_gameobject(); + for (map_id, instance_id) in [ + (MAP_ID, DEADMINES_INSTANCE + 1), + (1, DEADMINES_INSTANCE + 2), + (MAP_ID, 0), + ] { + let mut door = doors + .guid() + .find(GO_FIXTURE_BAND | u64::from(entry)) + .ok_or_else(|| format!("fixture door {entry} is missing"))?; + door.guid = DOOR_RELOAD_COPY_BAND | (instance_id << 24) | u64::from(entry); + door.map_id = map_id; + door.instance_id = instance_id; + door.state = 0; + doors.guid().delete(door.guid); + doors.insert(door); + } + doors.guid().delete(GO_FIXTURE_BAND | u64::from(entry)); + } + Ok(()) +} + +/// Requires debug_deadmines_prepare_door_reload; repeatable after a node restart. +#[reducer] +pub fn debug_deadmines_recreate_boss_doors(ctx: &ReducerContext) -> Result<(), String> { + crate::package::fixture::require_no_imported_content(ctx)?; + let doors = ctx.db.game_gameobject(); + for entry in [FACTORY_DOOR, MAST_ROOM_DOOR, FOUNDRY_DOOR] { + let source_guid = + DOOR_RELOAD_COPY_BAND | ((DEADMINES_INSTANCE + 1) << 24) | u64::from(entry); + let mut door = doors + .guid() + .find(source_guid) + .ok_or_else(|| format!("fixture source door {entry} is missing"))?; + door.guid = GO_FIXTURE_BAND | u64::from(entry); + door.instance_id = DEADMINES_INSTANCE; + door.state = 0; + doors.guid().delete(door.guid); + doors.insert(door); + } + Ok(()) +} 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 +408,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..45bb78c --- /dev/null +++ b/dungeons/testsuite/tests/deadmines_doors.rs @@ -0,0 +1,74 @@ +use std::time::Duration; + +use lyracore_test_support::{poll_until, Standalone}; + +fn boss_doors(node: &Standalone, map_id: u32, instance_id: u64) -> Vec<(u32, u8)> { + let mut doors: Vec<_> = node + .query_rows(&format!( + "SELECT template_entry, state FROM game_gameobject WHERE instance_id = {instance_id} AND map_id = {map_id}" + )) + .into_iter() + .filter_map(|row| { + let entry = row["template_entry"].parse::().unwrap(); + [13965, 16400, 16399] + .contains(&entry) + .then(|| (entry, row["state"].parse::().unwrap())) + }) + .collect(); + doors.sort_unstable(); + doors +} + +fn completed_bosses(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(); + (encounter_id < 3).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_completed_boss_doors_reopen_after_restart_and_recreation() { + let mut node = Standalone::start_persistent("deadmines-door-reload"); + node.publish_module(); + for stage in [ + "debug_deadmines_begin", + "debug_deadmines_rhahkzor_falls", + "debug_deadmines_shredder_ejects_sneed", + "debug_deadmines_sneed_falls", + "debug_deadmines_gilnid_falls", + "debug_deadmines_prepare_door_reload", + ] { + node.assert_call(stage, &[]); + } + assert_eq!(completed_bosses(&node), [(0, 2), (1, 2), (2, 2)]); + assert!(boss_doors(&node, 36, 936).is_empty()); + + node.restart_persistent(); + assert_eq!(completed_bosses(&node), [(0, 2), (1, 2), (2, 2)]); + assert!(boss_doors(&node, 36, 936).is_empty()); + + for _ in 0..2 { + node.assert_call("debug_deadmines_recreate_boss_doors", &[]); + assert!( + poll_until(Duration::from_secs(10), || { + boss_doors(&node, 36, 936) == [(13965, 1), (16399, 1), (16400, 1)] + }), + "recreated boss doors did not reopen from saved completion" + ); + for (map_id, instance_id) in [(36, 937), (1, 938), (36, 0)] { + assert_eq!( + boss_doors(&node, map_id, instance_id), + [(13965, 0), (16399, 0), (16400, 0)], + "door restoration escaped the completed encounter" + ); + } + assert_eq!(completed_bosses(&node), [(0, 2), (1, 2), (2, 2)]); + } +} From e65a603c9a93e2d2170d2de045e9680ba77ee3ea Mon Sep 17 00:00:00 2001 From: Mathias Hartveit Date: Thu, 15 Oct 2026 14:36:14 +0200 Subject: [PATCH 2/2] test(dungeons): check encounter persistence through real restarts --- dungeons/README.md | 34 +++++---- dungeons/src/deadmines.rs | 29 +------- dungeons/src/deadmines_verify.rs | 80 +------------------- dungeons/testsuite/tests/deadmines_doors.rs | 81 +++++++++++---------- 4 files changed, 68 insertions(+), 156 deletions(-) diff --git a/dungeons/README.md b/dungeons/README.md index 982257f..47588af 100644 --- a/dungeons/README.md +++ b/dungeons/README.md @@ -28,21 +28,14 @@ module, so keep item names dungeon-prefixed. ## Deadmines - Rhahk'Zor, Sneed, and Gilnid each open their door on death; Sneed's Shredder ejects Sneed. - If a completed encounter's door is recreated closed, the next creature tick opens it from saved - completion. A reset, unfinished encounter, or another instance keeps its own door state. - Firing the Defias Cannon breaches the Iron Clad Door. - Mr. Smite rearms at his chest at 66% and 33% health: dual Reavers, then his two-hand hammer. Entries and positions are dump-verified against classic-db z2815. Omitted: Smite's stomp stun (no vanilla data row backs it). -The restored boss doors follow the load behavior in -[CMaNGOS's pinned instance script](https://github.com/cmangos/mangos-classic/blob/ca0775fe352fb67a0e82dc6051f0563187d569a5/src/game/AI/ScriptDevAI/scripts/eastern_kingdoms/deadmines/instance_deadmines.cpp#L81-L98), -also represented by -[OMaNGOS's pinned Deadmines script](https://tangled.org/carl.tngl.sh/OMaNGOS/blob/d3f8c589aeb06d8a878050a4a11256479011a031/crates/content/src/eastern_kingdoms/deadmines.rs). -The Package checks live gameobjects indexed to map 36. Core has no gameobject-created hook, so -the Package uses its existing tick marker and calls `open_door` only for a closed boss door whose -own encounter is Done. It does not replay the boss death. +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 @@ -53,8 +46,21 @@ 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 completes all three boss encounters through the -kill path, deletes their door rows, restarts the node, and recreates closed doors twice. Each door -must reopen without another boss death. Closed copies in reset, InProgress, and Failed encounters, -another map, and instance 0 must stay closed. Core supplies the private fixture through -`lyracore-test-support`; the runner selects Core and keeps build output outside the Package. +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 1a3433b..9b84e04 100644 --- a/dungeons/src/deadmines.rs +++ b/dungeons/src/deadmines.rs @@ -1,9 +1,9 @@ -//! Read dungeons/README.md#deadmines before changing encounter behavior or recovery. +//! Read dungeons/README.md#deadmines before changing encounter behavior. use spacetimedb::ReducerContext; -use crate::package::encounter::{self, DOOR_OPEN_STATE, ENCOUNTER_DONE}; -use crate::tables::{game_gameobject, game_instance, game_world_entity}; +use crate::package::encounter::{self, ENCOUNTER_DONE}; +use crate::tables::{game_gameobject, game_world_entity}; pub(crate) const MAP_ID: u32 = 36; @@ -95,29 +95,6 @@ crate::game_hook!(on_creature_death, fn deadmines_boss_died(ctx, payload) { } }); -crate::game_tick_pass!(fn deadmines_restore_completed_boss_doors(ctx) { - // Core has no gameobject-created hook; recovery runs on the creature tick. - for door in ctx.db.game_gameobject().by_map().filter(&MAP_ID) { - if door.instance_id == 0 || door.state == DOOR_OPEN_STATE { - continue; - } - let encounter_id = match door.template_entry { - FACTORY_DOOR => ENCOUNTER_RHAHKZOR, - MAST_ROOM_DOOR => ENCOUNTER_SNEED, - FOUNDRY_DOOR => ENCOUNTER_GILNID, - _ => continue, - }; - if ctx.db.game_instance().instance_id().find(door.instance_id) - .is_none_or(|instance| instance.map_id != MAP_ID) - { - continue; - } - if encounter::get_encounter_state(ctx, door.instance_id, encounter_id) == ENCOUNTER_DONE { - open_door_logged(ctx, door.template_entry, door.instance_id); - } - } -}); - crate::game_hook!(on_go_used, fn defias_cannon_fired(ctx, payload) { if payload.go_entry != DEFIAS_CANNON || payload.instance_id == 0 { return; diff --git a/dungeons/src/deadmines_verify.rs b/dungeons/src/deadmines_verify.rs index b180022..f92e576 100644 --- a/dungeons/src/deadmines_verify.rs +++ b/dungeons/src/deadmines_verify.rs @@ -10,9 +10,7 @@ use super::deadmines::{ MR_SMITE, RHAHKZOR, SMITES_CHEST, SMITES_MIGHTY_HAMMER, SMITES_REAVER, SMITE_FIRST_STAND_PCT, SMITE_SECOND_STAND_PCT, SNEED, SNEEDS_SHREDDER, }; -use crate::package::encounter::{ - self, DOOR_OPEN_STATE, ENCOUNTER_DONE, ENCOUNTER_FAILED, ENCOUNTER_IN_PROGRESS, -}; +use crate::package::encounter::{self, DOOR_OPEN_STATE, ENCOUNTER_DONE}; use crate::tables::{ game_chat_event, game_creature_spline, game_creature_template, game_encounter_equip, game_gameobject, game_gameobject_template, game_instance, game_item_template, @@ -22,7 +20,6 @@ use crate::tables::{ const FIXTURE_LOW_BAND: u64 = 0x10_0000; const DEADMINES_INSTANCE: u64 = 936; const GO_FIXTURE_BAND: u64 = 0xF11D << 48; -const DOOR_RELOAD_COPY_BAND: u64 = GO_FIXTURE_BAND | (1 << 40); /// Requires a Shard without imported content; positions follow classic-db z2815. #[reducer] @@ -186,81 +183,6 @@ pub fn debug_deadmines_smite_falls(ctx: &ReducerContext) -> Result<(), String> { ) } -/// Requires all three boss encounters Done in fixture instance 936. -#[reducer] -pub fn debug_deadmines_prepare_door_reload(ctx: &ReducerContext) -> Result<(), String> { - crate::package::fixture::require_no_imported_content(ctx)?; - install_instance(ctx, MAP_ID, DEADMINES_INSTANCE + 1)?; - install_instance(ctx, 1, DEADMINES_INSTANCE + 2)?; - for (entry, encounter_id) in [ - (FACTORY_DOOR, ENCOUNTER_RHAHKZOR), - (MAST_ROOM_DOOR, ENCOUNTER_SNEED), - (FOUNDRY_DOOR, ENCOUNTER_GILNID), - ] { - require_done( - ctx, - encounter_id, - "boss completion is missing before reload", - )?; - encounter::set_encounter_state(ctx, DEADMINES_INSTANCE + 1, encounter_id, ENCOUNTER_DONE)?; - if entry == FACTORY_DOOR { - encounter::encounter_reset(ctx, DEADMINES_INSTANCE + 1, encounter_id); - } else { - encounter::set_encounter_state( - ctx, - DEADMINES_INSTANCE + 1, - encounter_id, - if entry == MAST_ROOM_DOOR { - ENCOUNTER_IN_PROGRESS - } else { - ENCOUNTER_FAILED - }, - )?; - } - encounter::set_encounter_state(ctx, DEADMINES_INSTANCE + 2, encounter_id, ENCOUNTER_DONE)?; - let doors = ctx.db.game_gameobject(); - for (map_id, instance_id) in [ - (MAP_ID, DEADMINES_INSTANCE + 1), - (1, DEADMINES_INSTANCE + 2), - (MAP_ID, 0), - ] { - let mut door = doors - .guid() - .find(GO_FIXTURE_BAND | u64::from(entry)) - .ok_or_else(|| format!("fixture door {entry} is missing"))?; - door.guid = DOOR_RELOAD_COPY_BAND | (instance_id << 24) | u64::from(entry); - door.map_id = map_id; - door.instance_id = instance_id; - door.state = 0; - doors.guid().delete(door.guid); - doors.insert(door); - } - doors.guid().delete(GO_FIXTURE_BAND | u64::from(entry)); - } - Ok(()) -} - -/// Requires debug_deadmines_prepare_door_reload; repeatable after a node restart. -#[reducer] -pub fn debug_deadmines_recreate_boss_doors(ctx: &ReducerContext) -> Result<(), String> { - crate::package::fixture::require_no_imported_content(ctx)?; - let doors = ctx.db.game_gameobject(); - for entry in [FACTORY_DOOR, MAST_ROOM_DOOR, FOUNDRY_DOOR] { - let source_guid = - DOOR_RELOAD_COPY_BAND | ((DEADMINES_INSTANCE + 1) << 24) | u64::from(entry); - let mut door = doors - .guid() - .find(source_guid) - .ok_or_else(|| format!("fixture source door {entry} is missing"))?; - door.guid = GO_FIXTURE_BAND | u64::from(entry); - door.instance_id = DEADMINES_INSTANCE; - door.state = 0; - doors.guid().delete(door.guid); - doors.insert(door); - } - Ok(()) -} - fn seed_gameobject( ctx: &ReducerContext, entry: u32, diff --git a/dungeons/testsuite/tests/deadmines_doors.rs b/dungeons/testsuite/tests/deadmines_doors.rs index 45bb78c..ff87166 100644 --- a/dungeons/testsuite/tests/deadmines_doors.rs +++ b/dungeons/testsuite/tests/deadmines_doors.rs @@ -1,16 +1,14 @@ -use std::time::Duration; +use lyracore_test_support::Standalone; -use lyracore_test_support::{poll_until, Standalone}; - -fn boss_doors(node: &Standalone, map_id: u32, instance_id: u64) -> Vec<(u32, u8)> { +fn doors(node: &Standalone) -> Vec<(u32, u8)> { let mut doors: Vec<_> = node - .query_rows(&format!( - "SELECT template_entry, state FROM game_gameobject WHERE instance_id = {instance_id} AND map_id = {map_id}" - )) + .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, 16400, 16399] + [13965, 16397, 16399, 16400] .contains(&entry) .then(|| (entry, row["state"].parse::().unwrap())) }) @@ -19,13 +17,15 @@ fn boss_doors(node: &Standalone, map_id: u32, instance_id: u64) -> Vec<(u32, u8) doors } -fn completed_bosses(node: &Standalone) -> Vec<(u32, u8)> { +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(); - (encounter_id < 3).then(|| (encounter_id, row["state"].parse::().unwrap())) + [0, 1, 2, 4] + .contains(&encounter_id) + .then(|| (encounter_id, row["state"].parse::().unwrap())) }) .collect(); states.sort_unstable(); @@ -34,41 +34,48 @@ fn completed_bosses(node: &Standalone) -> Vec<(u32, u8)> { #[test] #[ignore = "requires the pinned Standalone, Module Wasm, and this dungeons Package installed"] -fn deadmines_completed_boss_doors_reopen_after_restart_and_recreation() { - let mut node = Standalone::start_persistent("deadmines-door-reload"); +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_begin", "debug_deadmines_rhahkzor_falls", "debug_deadmines_shredder_ejects_sneed", - "debug_deadmines_sneed_falls", - "debug_deadmines_gilnid_falls", - "debug_deadmines_prepare_door_reload", + "debug_deadmines_cannon_breaches", ] { node.assert_call(stage, &[]); } - assert_eq!(completed_bosses(&node), [(0, 2), (1, 2), (2, 2)]); - assert!(boss_doors(&node, 36, 936).is_empty()); + 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!(completed_bosses(&node), [(0, 2), (1, 2), (2, 2)]); - assert!(boss_doors(&node, 36, 936).is_empty()); + assert_eq!( + doors(&node), + [(13965, 1), (16397, 1), (16399, 0), (16400, 0)] + ); + assert_eq!(door_encounters(&node), [(0, 2), (4, 2)]); - for _ in 0..2 { - node.assert_call("debug_deadmines_recreate_boss_doors", &[]); - assert!( - poll_until(Duration::from_secs(10), || { - boss_doors(&node, 36, 936) == [(13965, 1), (16399, 1), (16400, 1)] - }), - "recreated boss doors did not reopen from saved completion" - ); - for (map_id, instance_id) in [(36, 937), (1, 938), (36, 0)] { - assert_eq!( - boss_doors(&node, map_id, instance_id), - [(13965, 0), (16399, 0), (16400, 0)], - "door restoration escaped the completed encounter" - ); - } - assert_eq!(completed_bosses(&node), [(0, 2), (1, 2), (2, 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)]); }