Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<DebugVerifyEventaiSummonExpiryArgs> 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<Result<(), String>, __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<Result<(), String>, __sdk::InternalError>)
+ Send
+ 'static,
) -> __sdk::Result<()> {
self.imp
.invoke_reducer_with_callback(DebugVerifyEventaiSummonExpiryArgs {}, callback)
}
}
6 changes: 6 additions & 0 deletions gateway/src/stdb/bindings/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -2648,6 +2650,7 @@ pub enum Reducer {
expect_rule_state: bool,
},
DebugVerifyEventaiSpellGuardianCleanup,
DebugVerifyEventaiSummonExpiry,
DebugVerifyLethalDamageFloorFixture {
creature_guid: u64,
expected_health: u32,
Expand Down Expand Up @@ -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"
}
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions module/src/creatures/eventai/edges.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
);
Expand Down
102 changes: 91 additions & 11 deletions module/src/creatures/eventai/mobility.rs
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,20 @@ 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;
};
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;
Expand All @@ -624,18 +638,84 @@ pub fn expire_eventai_summon(ctx: &ReducerContext, expiry: CreatureAiSummonExpir
} else {
next_check_ms(remaining_ms)
};
ctx.db
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,
});
}

#[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()
.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,
});
.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) {
Expand Down
2 changes: 2 additions & 0 deletions module/src/creatures/eventai/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions module/src/debug/eventai.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
39 changes: 34 additions & 5 deletions module/tests/binding_quests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,32 @@ fn assert_first_aggro(node: &Standalone, summon: &BTreeMap<String, String>) {
);
}

fn assert_lifetime_checks_continue(node: &Standalone, summon: &BTreeMap<String, String>) {
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);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

#[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() {
Expand Down Expand Up @@ -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"]);
Expand Down Expand Up @@ -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!(
Expand Down
44 changes: 43 additions & 1 deletion module/tests/eventai_runtime_boundaries.rs
Original file line number Diff line number Diff line change
@@ -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]
Expand All @@ -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());
}