From d9c3c63c186d9b910b4d16f9822f1cf12bc443b4 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Mon, 7 Sep 2026 19:25:30 +0000 Subject: [PATCH 1/2] fix(creatures): retain temporary summon lifetime checks --- module/src/creatures/eventai/mobility.rs | 32 +++++++++++-------- module/tests/binding_quests.rs | 39 +++++++++++++++++++++--- 2 files changed, 54 insertions(+), 17 deletions(-) diff --git a/module/src/creatures/eventai/mobility.rs b/module/src/creatures/eventai/mobility.rs index 41a3092c8..b6689d9df 100644 --- a/module/src/creatures/eventai/mobility.rs +++ b/module/src/creatures/eventai/mobility.rs @@ -599,6 +599,16 @@ pub fn expire_eventai_summon(ctx: &ReducerContext, expiry: CreatureAiSummonExpir if ctx.sender() != ctx.database_identity() { return; } + let expiries = ctx.db.game_creature_ai_summon_expiry(); + let Some(current) = expiries.scheduled_id().find(expiry.scheduled_id) else { + return; + }; + if current.creature_guid != expiry.creature_guid || current.life_seq != expiry.life_seq { + return; + } + // The scheduled row still holds its unique creature key during this transaction. Remove + // this check before inserting the next one; an obsolete callback must not remove a newer row. + expiries.scheduled_id().delete(expiry.scheduled_id); let Some(creature) = ctx.db.game_world_entity().guid().find(expiry.creature_guid) else { crate::creatures::reset_creature_lifecycle(ctx, expiry.creature_guid); return; @@ -624,18 +634,16 @@ pub fn expire_eventai_summon(ctx: &ReducerContext, expiry: CreatureAiSummonExpir } else { next_check_ms(remaining_ms) }; - ctx.db - .game_creature_ai_summon_expiry() - .insert(CreatureAiSummonExpiry { - scheduled_id: 0, - scheduled_at: schedule_after(ctx, delay_ms), - creature_guid: expiry.creature_guid, - lifetime_ms: expiry.lifetime_ms, - remaining_ms, - last_checked_ms: now_ms, - // Carried, not re-taken: the summon is the same life across its lifetime checks. - life_seq: expiry.life_seq, - }); + expiries.insert(CreatureAiSummonExpiry { + scheduled_id: 0, + scheduled_at: schedule_after(ctx, delay_ms), + creature_guid: expiry.creature_guid, + lifetime_ms: expiry.lifetime_ms, + remaining_ms, + last_checked_ms: now_ms, + // Carried, not re-taken: the summon is the same life across its lifetime checks. + life_seq: expiry.life_seq, + }); } fn despawn_temporary_summon(ctx: &ReducerContext, creature_guid: u64) { diff --git a/module/tests/binding_quests.rs b/module/tests/binding_quests.rs index a884dc1c7..c2c43d11d 100644 --- a/module/tests/binding_quests.rs +++ b/module/tests/binding_quests.rs @@ -163,6 +163,32 @@ fn assert_first_aggro(node: &Standalone, summon: &BTreeMap) { ); } +fn assert_lifetime_checks_continue(node: &Standalone, summon: &BTreeMap) { + let query = format!( + "SELECT * FROM game_creature_ai_summon_expiry WHERE creature_guid = {}", + summon["guid"] + ); + let first = node + .query_rows(&query) + .into_iter() + .next() + .expect("living summon lost its expiry row"); + for _ in 0..2 { + let previous = node.query_rows(&query).remove(0); + assert!( + poll_until(POLL_TIMEOUT, || node + .query_rows(&query) + .first() + .is_some_and(|current| current["scheduled_id"] + != previous["scheduled_id"] + && current["life_seq"] == first["life_seq"])), + "living summon stopped its lifetime checks: {:?}", + node.query_rows(&query) + ); + assert_eq!(summoned(node, summon["entry"].parse().unwrap()).len(), 1); + } +} + #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn binding_items_require_the_circle_and_complete_both_quest_variants_without_replacing_a_pet() { @@ -293,6 +319,7 @@ fn binding_items_require_the_circle_and_complete_both_quest_variants_without_rep let summon = &summons[0]; assert_eq!(summon["owner_guid"], "0"); assert_first_aggro(&node, summon); + assert_lifetime_checks_continue(&node, summon); assert_eq!(summon["faction_template"], "14"); assert_eq!(summon["map_id"], location["map_id"]); assert_eq!(summon["instance_id"], location["instance_id"]); @@ -323,13 +350,15 @@ fn binding_items_require_the_circle_and_complete_both_quest_variants_without_rep summon["guid"] )); node.assert_call("gw_attack", &[PLAYER, &summon["guid"]]); - assert!(poll_until(POLL_TIMEOUT, || summoned(&node, creature) + let progress_query = + format!("SELECT * FROM game_character_quest WHERE quest_entry = {quest}"); + assert!(poll_until(POLL_TIMEOUT, || node + .query_rows(&progress_query) .first() - .is_some_and(|summon| summon["dead"] == "true"))); - let progress = node.query_rows(&format!( - "SELECT * FROM game_character_quest WHERE quest_entry = {quest}" - )); + .is_some_and(|row| row["counts"] == "1"))); + let progress = node.query_rows(&progress_query); assert_eq!(progress[0]["counts"], "1"); + assert!(poll_until(POLL_TIMEOUT, || summoned(&node, creature).is_empty())); assert_refused(&node, "debug_kill_creature", &[PLAYER, &summon["guid"]]); assert_eq!( node.query_rows(&format!( From 47184b723c5ba572499a4abbf49b1bf2c3fd837b Mon Sep 17 00:00:00 2001 From: T3 Code Date: Mon, 7 Sep 2026 20:14:16 +0000 Subject: [PATCH 2/2] test(creatures): prove temporary summon expiry boundaries --- ...ug_verify_eventai_summon_expiry_reducer.rs | 62 ++++++++++++++++ gateway/src/stdb/bindings/mod.rs | 6 ++ module/src/creatures/eventai/edges.rs | 4 +- module/src/creatures/eventai/mobility.rs | 72 +++++++++++++++++++ module/src/creatures/eventai/mod.rs | 2 + module/src/debug/eventai.rs | 13 ++++ module/tests/eventai_runtime_boundaries.rs | 44 +++++++++++- 7 files changed, 200 insertions(+), 3 deletions(-) create mode 100644 gateway/src/stdb/bindings/debug_verify_eventai_summon_expiry_reducer.rs diff --git a/gateway/src/stdb/bindings/debug_verify_eventai_summon_expiry_reducer.rs b/gateway/src/stdb/bindings/debug_verify_eventai_summon_expiry_reducer.rs new file mode 100644 index 000000000..cb0a1e65b --- /dev/null +++ b/gateway/src/stdb/bindings/debug_verify_eventai_summon_expiry_reducer.rs @@ -0,0 +1,62 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct DebugVerifyEventaiSummonExpiryArgs {} + +impl From for super::Reducer { + fn from(args: DebugVerifyEventaiSummonExpiryArgs) -> Self { + Self::DebugVerifyEventaiSummonExpiry + } +} + +impl __sdk::InModule for DebugVerifyEventaiSummonExpiryArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `debug_verify_eventai_summon_expiry`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait debug_verify_eventai_summon_expiry { + /// Request that the remote module invoke the reducer `debug_verify_eventai_summon_expiry` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`debug_verify_eventai_summon_expiry:debug_verify_eventai_summon_expiry_then`] to run a callback after the reducer completes. + fn debug_verify_eventai_summon_expiry(&self) -> __sdk::Result<()> { + self.debug_verify_eventai_summon_expiry_then(|_, _| {}) + } + + /// Request that the remote module invoke the reducer `debug_verify_eventai_summon_expiry` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. + fn debug_verify_eventai_summon_expiry_then( + &self, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl debug_verify_eventai_summon_expiry for super::RemoteReducers { + fn debug_verify_eventai_summon_expiry_then( + &self, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp + .invoke_reducer_with_callback(DebugVerifyEventaiSummonExpiryArgs {}, callback) + } +} diff --git a/gateway/src/stdb/bindings/mod.rs b/gateway/src/stdb/bindings/mod.rs index e097766e9..0618a22c9 100644 --- a/gateway/src/stdb/bindings/mod.rs +++ b/gateway/src/stdb/bindings/mod.rs @@ -257,6 +257,7 @@ pub mod debug_verify_combat_regen_reducer; pub mod debug_verify_eventai_quest_credit_fixture_reducer; pub mod debug_verify_eventai_revision_fixture_reducer; pub mod debug_verify_eventai_spell_guardian_cleanup_reducer; +pub mod debug_verify_eventai_summon_expiry_reducer; pub mod debug_verify_lethal_damage_floor_fixture_reducer; pub mod debug_verify_loot_tag_fixture_reducer; pub mod debug_verify_ranged_lethal_damage_floor_fixture_reducer; @@ -1235,6 +1236,7 @@ pub use debug_verify_combat_regen_reducer::debug_verify_combat_regen; pub use debug_verify_eventai_quest_credit_fixture_reducer::debug_verify_eventai_quest_credit_fixture; pub use debug_verify_eventai_revision_fixture_reducer::debug_verify_eventai_revision_fixture; pub use debug_verify_eventai_spell_guardian_cleanup_reducer::debug_verify_eventai_spell_guardian_cleanup; +pub use debug_verify_eventai_summon_expiry_reducer::debug_verify_eventai_summon_expiry; pub use debug_verify_lethal_damage_floor_fixture_reducer::debug_verify_lethal_damage_floor_fixture; pub use debug_verify_loot_tag_fixture_reducer::debug_verify_loot_tag_fixture; pub use debug_verify_ranged_lethal_damage_floor_fixture_reducer::debug_verify_ranged_lethal_damage_floor_fixture; @@ -2648,6 +2650,7 @@ pub enum Reducer { expect_rule_state: bool, }, DebugVerifyEventaiSpellGuardianCleanup, + DebugVerifyEventaiSummonExpiry, DebugVerifyLethalDamageFloorFixture { creature_guid: u64, expected_health: u32, @@ -3773,6 +3776,7 @@ impl __sdk::Reducer for Reducer { Reducer::DebugVerifyEventaiSpellGuardianCleanup => { "debug_verify_eventai_spell_guardian_cleanup" } + Reducer::DebugVerifyEventaiSummonExpiry => "debug_verify_eventai_summon_expiry", Reducer::DebugVerifyLethalDamageFloorFixture { .. } => { "debug_verify_lethal_damage_floor_fixture" } @@ -5200,6 +5204,8 @@ Reducer::DebugVerifyEventaiRevisionFixture{ }), Reducer::DebugVerifyEventaiSpellGuardianCleanup => __sats::bsatn::to_vec(&debug_verify_eventai_spell_guardian_cleanup_reducer::DebugVerifyEventaiSpellGuardianCleanupArgs { }), +Reducer::DebugVerifyEventaiSummonExpiry => __sats::bsatn::to_vec(&debug_verify_eventai_summon_expiry_reducer::DebugVerifyEventaiSummonExpiryArgs { + }), Reducer::DebugVerifyLethalDamageFloorFixture{ creature_guid, expected_health, diff --git a/module/src/creatures/eventai/edges.rs b/module/src/creatures/eventai/edges.rs index 6153a2f71..73e8cf077 100644 --- a/module/src/creatures/eventai/edges.rs +++ b/module/src/creatures/eventai/edges.rs @@ -593,10 +593,10 @@ mod eventai_gate_tripwire { "`place_summon` no longer pins the claim's own id as the summon's life number, so the \ summon has no identity to Gate on. Body was:\n{place}" ); - let expire = code_of(mobility, "pub fn expire_eventai_summon("); + let expire = code_of(mobility, "fn apply_summon_expiry("); assert!( expire.contains("life_seq: expiry.life_seq,"), - "`expire_eventai_summon` no longer carries the summon's life number across its \ + "the summon expiry body no longer carries the summon's life number across its \ re-insert, so a healthy summon changes identity on every lifetime check and its Relay \ Runs are refused. Body was:\n{expire}" ); diff --git a/module/src/creatures/eventai/mobility.rs b/module/src/creatures/eventai/mobility.rs index b6689d9df..3d5a0ecd1 100644 --- a/module/src/creatures/eventai/mobility.rs +++ b/module/src/creatures/eventai/mobility.rs @@ -599,6 +599,10 @@ pub fn expire_eventai_summon(ctx: &ReducerContext, expiry: CreatureAiSummonExpir if ctx.sender() != ctx.database_identity() { return; } + apply_summon_expiry(ctx, expiry); +} + +fn apply_summon_expiry(ctx: &ReducerContext, expiry: CreatureAiSummonExpiry) { let expiries = ctx.db.game_creature_ai_summon_expiry(); let Some(current) = expiries.scheduled_id().find(expiry.scheduled_id) else { return; @@ -646,6 +650,74 @@ pub fn expire_eventai_summon(ctx: &ReducerContext, expiry: CreatureAiSummonExpir }); } +#[cfg(feature = "debug_reducers")] +pub(crate) fn verify_summon_expiry_boundaries_for_debug( + ctx: &ReducerContext, + caster: &crate::WorldEntity, + entry: u32, +) -> Result<(), String> { + let location = |lifetime_ms| SummonLocation { + x: caster.x + 2.0, + y: caster.y, + z: caster.z, + orientation: caster.orientation, + lifetime_ms, + }; + + let replaced_guid = place_temporary_summon(ctx, caster, entry, location(60_000))?; + let stale = ctx + .db + .game_creature_ai_summon_expiry() + .creature_guid() + .find(replaced_guid) + .ok_or_else(|| "fixture summon has no expiry callback".to_string())?; + despawn_temporary_summon(ctx, replaced_guid); + + let replacement_sequence = claim_summon_sequence(ctx, 60_000); + let replacement_location = location(60_000); + let summoner = super::engine::unit_of(caster); + place_summon( + ctx, + replacement_sequence, + replaced_guid, + entry, + &replacement_location, + &summoner, + ); + super::edges::eventai_on_summoned(ctx, caster.guid, replaced_guid, entry); + let replacement = ctx + .db + .game_creature_ai_summon_expiry() + .creature_guid() + .find(replaced_guid) + .ok_or_else(|| "replacement summon has no expiry callback".to_string())?; + if replacement.life_seq == stale.life_seq { + return Err("replacement summon reused the obsolete life number".to_string()); + } + + apply_summon_expiry(ctx, stale); + let retained = ctx + .db + .game_creature_ai_summon_expiry() + .creature_guid() + .find(replaced_guid) + .ok_or_else(|| "obsolete callback removed the replacement expiry".to_string())?; + if retained.scheduled_id != replacement.scheduled_id + || retained.life_seq != replacement.life_seq + || ctx + .db + .game_world_entity() + .guid() + .find(replaced_guid) + .is_none() + { + return Err("obsolete callback changed the replacement summon".to_string()); + } + + place_temporary_summon(ctx, caster, entry, location(5_000))?; + Ok(()) +} + fn despawn_temporary_summon(ctx: &ReducerContext, creature_guid: u64) { crate::creatures::despawn_creature_entity(ctx, creature_guid); } diff --git a/module/src/creatures/eventai/mod.rs b/module/src/creatures/eventai/mod.rs index e71eb3827..d58166ee1 100644 --- a/module/src/creatures/eventai/mod.rs +++ b/module/src/creatures/eventai/mod.rs @@ -42,6 +42,8 @@ pub(crate) use fixtures::seed_on_aggro_fixtures; pub(crate) use loader::replace_definition_for_debug; #[cfg(test)] pub(crate) use mobility::summon_lifetime_after; +#[cfg(feature = "debug_reducers")] +pub(crate) use mobility::verify_summon_expiry_boundaries_for_debug; pub(crate) use mobility::{drop_summon_expiry, ranged_posture, react_state}; pub use mobility::{ expire_eventai_summon, fire_eventai_forced_despawn, CreatureAiForcedDespawn, diff --git a/module/src/debug/eventai.rs b/module/src/debug/eventai.rs index 682ced18e..41431ff17 100644 --- a/module/src/debug/eventai.rs +++ b/module/src/debug/eventai.rs @@ -89,3 +89,16 @@ pub fn debug_verify_eventai_spell_guardian_cleanup(ctx: &ReducerContext) -> Resu } Ok(()) } + +/// Create a replacement summon, deliver its prior life's callback, and leave a short-lived +/// disengaged summon for the real scheduler to expire. +#[reducer] +pub fn debug_verify_eventai_summon_expiry(ctx: &ReducerContext) -> Result<(), String> { + let owner = ctx + .db + .game_world_entity() + .guid() + .find(FIXTURE_OWNER_GUID) + .ok_or_else(|| "fixture EventAI owner is unavailable".to_string())?; + crate::creatures::verify_summon_expiry_boundaries_for_debug(ctx, &owner, FIXTURE_OWNER_ENTRY) +} diff --git a/module/tests/eventai_runtime_boundaries.rs b/module/tests/eventai_runtime_boundaries.rs index 8c8d800a8..1f8c600aa 100644 --- a/module/tests/eventai_runtime_boundaries.rs +++ b/module/tests/eventai_runtime_boundaries.rs @@ -1,6 +1,6 @@ mod support; -use support::Standalone; +use support::{poll_until, Standalone, POLL_TIMEOUT}; /// Runs only when requested because it builds and publishes the Wasm module to its own standalone. #[test] @@ -10,3 +10,45 @@ fn empty_relay_catalogue_and_spell_guardian_cleanup_apply_through_real_reducers( standalone.publish_module_anonymous(); standalone.assert_call_anonymous("debug_verify_eventai_spell_guardian_cleanup", &[]); } + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn obsolete_callbacks_leave_replacement_summons_and_living_summons_expire_naturally() { + let mut standalone = Standalone::start("eventai-summon-expiry-boundaries"); + standalone.publish_module(); + standalone.assert_call("debug_verify_eventai_summon_expiry", &[]); + + let natural = standalone + .query_rows( + "SELECT creature_guid FROM game_creature_ai_summon_expiry WHERE lifetime_ms = 5000", + ) + .remove(0)["creature_guid"] + .clone(); + assert_eq!( + standalone + .query_rows(&format!( + "SELECT guid FROM game_world_entity WHERE guid = {natural}" + )) + .len(), + 1, + "the living summon must be observable before its allowed lifetime ends" + ); + assert!( + poll_until(POLL_TIMEOUT, || standalone + .query_rows(&format!( + "SELECT guid FROM game_world_entity WHERE guid = {natural}" + )) + .is_empty()), + "the disengaged summon did not expire after its allowed lifetime" + ); + assert!(standalone + .query_rows(&format!( + "SELECT * FROM game_creature_ai_summon_expiry WHERE creature_guid = {natural}" + )) + .is_empty()); + assert!(standalone + .query_rows(&format!( + "SELECT * FROM game_creature_ai_summon_origin WHERE creature_guid = {natural}" + )) + .is_empty()); +}