From ac8dee7ee235e5d0a47d9f34919faa12266fc1c1 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Fri, 16 Oct 2026 07:33:14 +0200 Subject: [PATCH 1/2] perf(module): stop retaining transient notification rows --- .github/workflows/module-durable.yml | 1 + .github/workflows/rust.yml | 1 + crates/lyracore-test-support/src/lib.rs | 6 + .../lyracore-test-support/src/subscription.rs | 87 +++++++++++++ docs/architecture.md | 6 + docs/danger-zones.md | 32 +++++ docs/schema.md | 8 +- docs/testing.md | 5 +- gateway/src/codec/combat/subscriptions.rs | 6 +- gateway/src/codec/social/subscriptions.rs | 4 +- gateway/src/stdb/relays.rs | 3 +- gateway/tests/notification_events.rs | 123 ++++++++++++++++++ gateway/tests/schema_parity.rs | 20 +-- module/src/chat.rs | 6 +- module/src/combat/death.rs | 46 +------ module/src/combat/engage.rs | 1 + module/src/combat/mod.rs | 5 +- module/src/combat/swing.rs | 18 +-- module/src/gc.rs | 1 + module/src/lib.rs | 17 +-- module/src/spell/cast/resolve.rs | 4 +- module/src/spell/cast/targeting.rs | 2 +- module/src/spell/control.rs | 4 +- module/src/spell/mod.rs | 13 +- module/src/spell/proc.rs | 2 +- module/src/spell/scheduler.rs | 44 ++++--- module/src/spell/tables.rs | 2 + module/src/tables.rs | 17 +-- module/src/tripwires.rs | 67 ++++++++-- module/tests/cast_global_cooldown.rs | 32 +++-- module/tests/combat_log.rs | 40 +++--- module/tests/duel_killing_blow.rs | 17 ++- module/tests/full_world_eventai.rs | 15 ++- module/tests/hearthstone.rs | 13 +- module/tests/lethal_damage_floor.rs | 27 ++-- module/tests/notification_tables.rs | 34 +++++ module/tests/proximity_emotes.rs | 4 +- module/tests/queued_strike_fizzle.rs | 51 ++++---- module/tests/random_property_stats.rs | 18 ++- module/tests/scripted_spell_effect.rs | 20 +-- module/tests/support/mod.rs | 40 ++++++ module/tests/swing_error.rs | 43 +++--- 42 files changed, 641 insertions(+), 264 deletions(-) create mode 100644 crates/lyracore-test-support/src/subscription.rs create mode 100644 gateway/tests/notification_events.rs create mode 100644 module/tests/notification_tables.rs diff --git a/.github/workflows/module-durable.yml b/.github/workflows/module-durable.yml index b99ba9d96..a984d16d6 100644 --- a/.github/workflows/module-durable.yml +++ b/.github/workflows/module-durable.yml @@ -68,6 +68,7 @@ jobs: cargo test -p lyracore-gateway --bin lyracore-gateway stdb::armor::tests::resistance_projection_adds_property_effects_only_while_the_item_is_worn_and_unbroken -- --ignored --exact cargo test -p lyracore-gateway --test provision_cli provision_writes_the_account_to_the_world_shard_and_realm_core -- --ignored --exact cargo test -p lyracore-gateway --test startup_relays -- --ignored --test-threads=1 + cargo test -p lyracore-gateway --test notification_events -- --ignored --test-threads=1 cargo test -p lyracore-gateway --bin lyracore-gateway stdb::world_view::item_durable_tests::live_worn_item_insert_enchant_and_delete_reach_a_nearby_peer -- --ignored --exact - name: Run Benilla against private Module instances diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index a91998982..7e05e643d 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -53,6 +53,7 @@ jobs: - run: cargo test -p lyracore-importer - run: cargo test -p lyracore-module --lib --features=debug_reducers - run: cargo test -p lyracore-module --test build_script + - run: cargo test -p lyracore-module --test notification_tables - run: cargo test -p lyracore-module --test package_api_lint - run: cargo test -p lyracore-package-delta - run: cargo test -p lyracore-gateway diff --git a/crates/lyracore-test-support/src/lib.rs b/crates/lyracore-test-support/src/lib.rs index 8ddcaab07..a7b0ad6d9 100644 --- a/crates/lyracore-test-support/src/lib.rs +++ b/crates/lyracore-test-support/src/lib.rs @@ -1,7 +1,9 @@ mod module_wasm; pub mod source_scan; +mod subscription; pub use module_wasm::{gateway_binary, module_bytes}; +pub use subscription::TableSubscription; use std::collections::{BTreeMap, BTreeSet}; use std::ffi::OsString; @@ -268,6 +270,10 @@ impl Standalone { command.output().expect("failed to call reducer") } + pub fn subscribe(&self, queries: &[&str]) -> TableSubscription { + TableSubscription::start(self.command(), &self.server, &self.database, queries) + } + pub fn capture_updates( &self, query: &str, diff --git a/crates/lyracore-test-support/src/subscription.rs b/crates/lyracore-test-support/src/subscription.rs new file mode 100644 index 000000000..594e52514 --- /dev/null +++ b/crates/lyracore-test-support/src/subscription.rs @@ -0,0 +1,87 @@ +use std::io::{BufRead, BufReader}; +use std::process::{Child, Command, Stdio}; +use std::sync::{Arc, Mutex}; +use std::thread::{self, JoinHandle}; + +use serde_json::Value; + +pub struct TableSubscription { + child: Child, + reader: Option>, + updates: Arc, String>>>, +} + +impl TableSubscription { + pub(crate) fn start(mut command: Command, server: &str, shard: &str, queries: &[&str]) -> Self { + let child = command + .args([ + "subscribe", + "-s", + server, + "--print-initial-update", + "--timeout", + "180", + shard, + ]) + .args(queries) + .stdout(Stdio::piped()) + .spawn() + .expect("failed to subscribe to fixture"); + let mut subscription = Self { + child, + reader: None, + updates: Arc::new(Mutex::new(Ok(Vec::new()))), + }; + let mut reader = BufReader::new(subscription.child.stdout.take().unwrap()); + let mut initial = String::new(); + assert!(reader.read_line(&mut initial).unwrap() > 0); + let _: Value = serde_json::from_str(&initial).expect("initial subscription result"); + let recorded = subscription.updates.clone(); + subscription.reader = Some(thread::spawn(move || { + for line in reader.lines() { + let update = line.map_err(|error| error.to_string()).and_then(|line| { + serde_json::from_str(&line).map_err(|error| error.to_string()) + }); + match update { + Ok(update) => recorded.lock().unwrap().as_mut().unwrap().push(update), + Err(error) => { + *recorded.lock().unwrap() = Err(error); + return; + } + } + } + *recorded.lock().unwrap() = Err("fixture subscription ended".into()); + })); + subscription + } + + pub fn inserts(&self, table: &str) -> Vec { + self.rows(table, "inserts") + } + + pub fn deletes(&self, table: &str) -> Vec { + self.rows(table, "deletes") + } + + fn rows(&self, table: &str, change: &str) -> Vec { + self.updates + .lock() + .unwrap() + .as_ref() + .expect("fixture subscription failed") + .iter() + .flat_map(|update| update[table][change].as_array().into_iter().flatten()) + .cloned() + .collect() + } +} + +impl Drop for TableSubscription { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + if let Some(reader) = self.reader.take() { + let _ = reader.join(); + } + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 98ef76017..8ba6f29fd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -584,6 +584,12 @@ table's insert, update and delete callbacks and registered before the subscripti per Shard). Each lives on its connection generation, so a reconnect rebuilds it from the fresh subscription. +Combat, spell cast, spell impact, Speech and emote notifications use native `*_event_live` tables. +The ordinary predecessors retain their published schemas for additive upgrades and expire through the +event reaper. The Gateway handles inserts without caching or replaying notification rows. Group events +still use their ordered committed batch, and login still reads retained System Messages. Read +[danger-zones.md](./danger-zones.md) before publishing the matching Module and Gateway. + ### 5.4 RLS is not a universal backstop — state this plainly - **Anyone who can reach the node's `:3000` port** can connect with no token, get a minted diff --git a/docs/danger-zones.md b/docs/danger-zones.md index 0539c6cdd..7c887655f 100644 --- a/docs/danger-zones.md +++ b/docs/danger-zones.md @@ -84,6 +84,38 @@ any line in §1 needs a human review before it ships, whoever or whatever wrote realm-wide or partition-managing read, with a one-line justification, and delete budget as scans are replaced with helpers — a budget larger than the real count fails its own test. +### Native Relay events + +Combat, spell cast, spell impact, Speech and emote notifications use native SpacetimeDB event +tables. Each adds `_live` to its original table name, such as `game_combat_event_live`. +The original `game_combat_event`, `game_spell_cast_event`, `game_spell_impact_event`, +`game_chat_event` and `game_emote_event` remain ordinary tables with their published schemas. +The two tables share each row type. The Module writes new notifications only to the native table. +The event reaper expires rows left in the original table after an upgrade. + +SpacetimeDB refuses a change to an existing table's event flag. Keep the old declarations and +their indexes when publishing this additive change. Stop the Gateway, publish the Module to +every configured Shard, then start the matching Gateway. An old Gateway subscribes to the old +names and will miss new notifications. Do not clear a Shard to make this migration apply. + +Native events reach connected subscribers through insert callbacks. They have no initial replay, +cached rows, update callbacks or delete callbacks. The Gateway uses the same row fields, audience +rules and packet rendering for the new tables. Cast notifications carry their start, completion +or outcome in each row; the Relays do not read a retained event batch. A reconnect restores +durable state and receives subsequent notifications. Events emitted during a Transport Loss +are not replayed. Event inserts still enter the commit log, so this change does not replace +history retention. See the [event table contract](https://spacetimedb.com/docs/tables/event-tables/). + +Keep `game_group_event` ordinary. `GroupEventCursor` reads the committed batch and sorts its ids +before delivery. Keep `game_system_message_event` ordinary because login reads pending messages. +Pending casts and impacts, Transfer Intent, party command intent and receipts, Mail, invitations +and other durable work keep their current storage and completion rules. + +Durable tests observe notifications through subscriptions opened before the action. A SQL read +after the action sees an empty native event table. The old debug reducer +`debug_verify_ranged_lethal_damage_floor_fixture` was removed because it read retained cast logs. +Its durable test now checks the subscribed damage event and the target's durable health. + ## 2. Tooling gotchas (will silently mislead you) - **`spacetime sql` (2.5):** NO `ORDER BY`, NO `IN`/subqueries, NO `Timestamp`/`Identity` literal. A diff --git a/docs/schema.md b/docs/schema.md index 266bef7df..874123f99 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -225,9 +225,9 @@ scan on a sharded realm silently returns a subset rather than erroring, and ever `module/src/tripwires.rs::partition_discipline_tripwire` against a whitelist that only ever shrinks. Six sibling tables carry the **identical** `(map_id, instance_id, grid_x, grid_y)` key so they can -ride the same AOI box: `game_entity_motion`, `game_creature_spline`, `game_combat_event`, -`game_spell_cast_event`, `game_spell_impact_event`, `game_emote_event`. `game_roll_event` keeps the -same key, but nothing writes it: `/roll` is a Group Broadcast. +ride the same AOI box: `game_entity_motion`, `game_creature_spline`, `game_combat_event_live`, +`game_spell_cast_event_live`, `game_spell_impact_event_live`, `game_emote_event_live`. +`game_roll_event` keeps the same key, but nothing writes it: `/roll` is a Group Broadcast. `game_gameobject`'s grid key is only three columns (no `instance_id`). ### `game_character` — the durable character (`module/src/character.rs:7`) @@ -307,7 +307,7 @@ whose ownership has closed or expired can use null ownership. ### Chat (`module/src/chat.rs`, `module/src/realm_chat.rs`, `module/src/channel.rs`) -`game_chat_event` is the public `[event]` row for one say, yell, `/e` or creature line on the +`game_chat_event_live` is the public native event row for one say, yell, `/e` or creature line on the speaker's own Shard. The Gateway scopes it to listeners in range when it relays it. Only `apply_send_chat` (a Character) and `apply_send_chat_to` (EventAI) write it. A Character's line passes the language Gate first. diff --git a/docs/testing.md b/docs/testing.md index 05fd73fea..769543211 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -30,6 +30,7 @@ cargo clippy --workspace --all-targets --all-features -- -D warnings cargo test -p lyracore-importer cargo test -p lyracore-module --lib --features=debug_reducers cargo test -p lyracore-module --test build_script +cargo test -p lyracore-module --test notification_tables cargo test -p lyracore-module --test package_api_lint cargo test -p lyracore-package-delta cargo test -p lyracore-gateway @@ -49,8 +50,8 @@ compile checks their types and generated registrations. ## Durable tier -From `.github/workflows/module-durable.yml`. Each test in `module/tests/` is `#[ignore]`d because it -starts its own SpacetimeDB standalone on a free loopback port, publishes the Module with +From `.github/workflows/module-durable.yml`. Durable tests in `module/tests/` are `#[ignore]`d because +each starts its own SpacetimeDB standalone on a free loopback port, publishes the Module with `--features=debug_reducers,package_test_fixture`, calls reducers and reads the result. The `lyracore-test-support` crate owns that fixture. Standalone logs go to `lyracore-standalone-logs` under the system temp directory. diff --git a/gateway/src/codec/combat/subscriptions.rs b/gateway/src/codec/combat/subscriptions.rs index 2ef78c96b..5dde1fe94 100644 --- a/gateway/src/codec/combat/subscriptions.rs +++ b/gateway/src/codec/combat/subscriptions.rs @@ -1139,7 +1139,7 @@ crate::stdb::relays::declare_relays! { insert(Snapshot) => |context, _event, row| dynobj_appeared(&context.view, context.shard, row); delete(Snapshot) => |context, _event, row| dynobj_vanished(&context.view, context.shard, row); } - game_combat_event [false] { + game_combat_event_live [false] { insert(Snapshot) => |context, _event, row| combat_event_appeared(&context.view, context.shard, row); } game_melee_attack [false] { @@ -1147,10 +1147,10 @@ crate::stdb::relays::declare_relays! { update(Snapshot) => |context, _event, old, row| melee_updated(&context.view, context.shard, old, row); delete(Snapshot) => |context, _event, row| melee_disengaged(&context.view, context.shard, row); } - game_spell_cast_event [false] { + game_spell_cast_event_live [false] { insert(Snapshot) => |context, _event, row| cast_event_appeared(&context.view, context.shard, row); } - game_spell_impact_event [false] { + game_spell_impact_event_live [false] { insert(Snapshot) => |context, _event, row| impact_appeared(&context.view, context.shard, row); } game_aura [false] { diff --git a/gateway/src/codec/social/subscriptions.rs b/gateway/src/codec/social/subscriptions.rs index 068d0867e..f29d275d0 100644 --- a/gateway/src/codec/social/subscriptions.rs +++ b/gateway/src/codec/social/subscriptions.rs @@ -1102,10 +1102,10 @@ crate::stdb::relays::declare_relays! { insert(Snapshot) => |context, _event, row| group_member_mirror_changed(&context.view, &context.coord, context.shard, row); delete(Snapshot) => |context, _event, row| group_member_mirror_changed(&context.view, &context.coord, context.shard, row); } - game_emote_event [false] { + game_emote_event_live [false] { insert(Snapshot) => |context, _event, row| emote_appeared(&context.view, &context.coord, context.shard, row); } - game_chat_event [false] { + game_chat_event_live [false] { insert(Snapshot) => |context, _event, row| chat_appeared(&context.view, &context.coord, context.shard, row); } game_group_event [true] { diff --git a/gateway/src/stdb/relays.rs b/gateway/src/stdb/relays.rs index 1b9a092e8..9abf8a3a8 100644 --- a/gateway/src/stdb/relays.rs +++ b/gateway/src/stdb/relays.rs @@ -1,6 +1,7 @@ use std::panic::{catch_unwind, AssertUnwindSafe}; use std::sync::Arc; +use spacetimedb_sdk::table::WithInsert; use spacetimedb_sdk::{Table, TableWithPrimaryKey}; use super::bindings::{EventContext, Reducer, RemoteTables}; @@ -82,7 +83,7 @@ fn guarded(label: &str, body: impl FnOnce()) { } } -pub(crate) fn insert>( +pub(crate) fn insert>( table: T, label: &'static str, context: RelayContext, diff --git a/gateway/tests/notification_events.rs b/gateway/tests/notification_events.rs new file mode 100644 index 000000000..2895cc83a --- /dev/null +++ b/gateway/tests/notification_events.rs @@ -0,0 +1,123 @@ +include!(concat!(env!("OUT_DIR"), "/bindings.rs")); + +use bindings::{DbConnection, GameChatEventLiveTableAccess, SubscriptionHandle}; +use lyracore_test_support::{actor, Standalone, POLL_TIMEOUT}; +use spacetimedb_sdk::{DbContext, EventTable}; +use std::sync::mpsc::{self, Receiver}; +use std::thread::JoinHandle; + +struct Delivery { + message: String, + cached_count: u64, + cached_rows: usize, +} + +struct Subscribed { + connection: DbConnection, + pump: Option>, + _subscription: SubscriptionHandle, + deliveries: Receiver, +} + +impl Subscribed { + fn connect(shard: &Standalone) -> Self { + let connection = DbConnection::builder() + .with_uri(shard.server()) + .with_database_name(shard.shard_name()) + .with_token(Some(shard.owner_token())) + .build() + .expect("fixture connection"); + let (delivery, deliveries) = mpsc::channel(); + connection + .db + .game_chat_event_live() + .on_insert(move |ctx, row| { + delivery + .send(Delivery { + message: row.message.clone(), + cached_count: ctx.db.game_chat_event_live().count(), + cached_rows: ctx.db.game_chat_event_live().iter().count(), + }) + .expect("fixture receiver remains connected"); + }); + let (applied, ready) = mpsc::channel(); + let failed = applied.clone(); + let subscription = connection + .subscription_builder() + .on_applied(move |_| { + applied.send(Ok(())).unwrap(); + }) + .on_error(move |_, error| { + failed.send(Err(error.to_string())).unwrap(); + }) + .subscribe(["SELECT * FROM game_chat_event_live"]); + let pump = connection.run_threaded(); + let subscribed = Self { + connection, + pump: Some(pump), + _subscription: subscription, + deliveries, + }; + ready + .recv_timeout(POLL_TIMEOUT) + .expect("subscription must apply") + .expect("subscription must succeed"); + subscribed + } + + fn expect_line(&self, message: &str) { + let delivery = self + .deliveries + .recv_timeout(POLL_TIMEOUT) + .expect("Speech insert"); + assert_eq!(delivery.message, message); + assert_eq!(delivery.cached_count, 0); + assert_eq!(delivery.cached_rows, 0); + } +} + +impl Drop for Subscribed { + fn drop(&mut self) { + let _ = self.connection.disconnect(); + if let Some(pump) = self.pump.take() { + let _ = pump.join(); + } + } +} + +fn speak(shard: &Standalone, message: &str) { + shard.assert_call( + "gw_send_chat", + &[ + &actor("1"), + "0", + "7", + &serde_json::to_string(message).unwrap(), + ], + ); +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn native_notifications_keep_transaction_order_without_cached_rows_or_reconnect_replay() { + let mut shard = Standalone::start("notification-events"); + shard.publish_module(); + shard.assert_call("claim_operator", &[]); + shard.assert_call("debug_spawn_player_entity", &["1"]); + let first = Subscribed::connect(&shard); + speak(&shard, "first"); + speak(&shard, "second"); + first.expect_line("first"); + first.expect_line("second"); + assert!(shard + .query_rows("SELECT * FROM game_chat_event_live") + .is_empty()); + assert!(shard.query_rows("SELECT * FROM game_chat_event").is_empty()); + drop(first); + + speak(&shard, "while disconnected"); + let next = Subscribed::connect(&shard); + assert!(next.deliveries.try_recv().is_err()); + speak(&shard, "after reconnect"); + next.expect_line("after reconnect"); +} diff --git a/gateway/tests/schema_parity.rs b/gateway/tests/schema_parity.rs index 22f74cadc..e3680f511 100644 --- a/gateway/tests/schema_parity.rs +++ b/gateway/tests/schema_parity.rs @@ -874,7 +874,7 @@ parity_test!(parity_game_breath_relay_event, "game_breath_relay_event", lyracore parity_test!(parity_game_dynamic_object, "game_dynamic_object", lyracore_module::DynamicObject, bindings::dynamic_object_type::DynamicObject, { guid, caster_guid, spell_id, map_id, instance_id, x, y, z, radius_yd, }); -parity_test!(parity_game_combat_event, "game_combat_event", lyracore_module::CombatEvent, bindings::combat_event_type::CombatEvent, { +parity_test!(parity_game_combat_event_live, "game_combat_event_live", lyracore_module::CombatEvent, bindings::combat_event_type::CombatEvent, { id, attacker_guid, target_guid, damage, hit_info, killing_blow, created_at, blocked_amount, ranged_spell_id, ammo_display_id, spell_swing, impact_delay_ms, map_id, instance_id, grid_x, grid_y, @@ -883,13 +883,13 @@ parity_test!(parity_game_melee_attack, "game_melee_attack", lyracore_module::Mel attacker_guid, target_guid, last_swing_ms, ranged_spell_id, last_offhand_swing_ms, rout_ends_ms, pursuit_ends_ms, leash_x, leash_y, swing_error, }); -parity_test!(parity_game_spell_cast_event, "game_spell_cast_event", lyracore_module::SpellCastEvent, bindings::spell_cast_event_type::SpellCastEvent, { +parity_test!(parity_game_spell_cast_event_live, "game_spell_cast_event_live", lyracore_module::SpellCastEvent, bindings::spell_cast_event_type::SpellCastEvent, { id, caster_guid, spell_id, created_at, target_guid, cast_time_ms, is_completion, damage, school, is_crit, resisted, absorbed, is_interrupted, cooldown_ms, delay_ms, healed, is_proc_log, swing_hit_info, client_initiated, map_id, instance_id, grid_x, grid_y, failure_reason, kind, miss_info, energized, power_type, }); -parity_test!(parity_game_spell_impact_event, "game_spell_impact_event", lyracore_module::SpellImpactEvent, bindings::spell_impact_event_type::SpellImpactEvent, { +parity_test!(parity_game_spell_impact_event_live, "game_spell_impact_event_live", lyracore_module::SpellImpactEvent, bindings::spell_impact_event_type::SpellImpactEvent, { id, caster_guid, target_guid, spell_id, created_at, damage, school, is_crit, resisted, absorbed, map_id, instance_id, grid_x, grid_y, }); @@ -984,10 +984,10 @@ parity_test!(parity_game_faction_template, "game_faction_template", lyracore_mod parity_test!(parity_game_encounter_equip, "game_encounter_equip", lyracore_module::EncounterEquip, bindings::encounter_equip_type::EncounterEquip, { creature_guid, instance_id, main_hand, off_hand, ranged, }); -parity_test!(parity_game_chat_event, "game_chat_event", lyracore_module::ChatEvent, bindings::chat_event_type::ChatEvent, { +parity_test!(parity_game_chat_event_live, "game_chat_event_live", lyracore_module::ChatEvent, bindings::chat_event_type::ChatEvent, { id, sender_guid, chat_type, language, message, created_at, target_guid, }); -parity_test!(parity_game_emote_event, "game_emote_event", lyracore_module::EmoteEvent, bindings::emote_event_type::EmoteEvent, { +parity_test!(parity_game_emote_event_live, "game_emote_event_live", lyracore_module::EmoteEvent, bindings::emote_event_type::EmoteEvent, { id, sender_guid, text_emote, emote_anim, created_at, target_guid, map_id, instance_id, grid_x, grid_y, }); @@ -997,8 +997,8 @@ const MANIFEST_TABLES: &[&str] = &[ "game_entity_motion", "game_creature_spline", "game_taxi_passenger_spline", - "game_chat_event", - "game_emote_event", + "game_chat_event_live", + "game_emote_event_live", "game_teleport_event", "game_xp_event", "game_levelup_event", @@ -1109,10 +1109,10 @@ const MANIFEST_TABLES: &[&str] = &[ "game_breath_relay_event", "game_instance_removal", "game_dynamic_object", - "game_combat_event", + "game_combat_event_live", "game_melee_attack", - "game_spell_cast_event", - "game_spell_impact_event", + "game_spell_cast_event_live", + "game_spell_impact_event_live", "game_creature_cast", "game_resurrect_request", "game_self_resurrect_option", diff --git a/module/src/chat.rs b/module/src/chat.rs index 00ef7dc85..cc4c61d05 100644 --- a/module/src/chat.rs +++ b/module/src/chat.rs @@ -11,6 +11,7 @@ pub const CHAT_EMOTE: u8 = lyracore_shared::chat::broadcast_chat::EMOTE; const MAX_CHAT_LEN: usize = 255; #[table(accessor = game_chat_event, public)] +#[table(accessor = game_chat_event_live, public, event)] pub struct ChatEvent { #[primary_key] #[auto_inc] @@ -123,7 +124,7 @@ fn write_chat_event( crate::terrain::zone_id_at(ctx, sender.map_id, sender.x, sender.y).unwrap_or(0); sender = ctx.db.game_world_entity().guid().update(sender); } - ctx.db.game_chat_event().insert(ChatEvent { + ctx.db.game_chat_event_live().insert(ChatEvent { id: 0, sender_guid: sender.guid, chat_type, @@ -162,6 +163,7 @@ pub struct ChannelEvent { public, index(accessor = by_grid, btree(columns = [map_id, instance_id, grid_x, grid_y])) )] +#[table(accessor = game_emote_event_live, public, event)] pub struct EmoteEvent { #[primary_key] #[auto_inc] @@ -193,7 +195,7 @@ pub(crate) fn apply_send_emote( return Err("dead players cannot emote".to_string()); } let (map_id, instance_id, grid_x, grid_y) = crate::helpers::entity_addr(&sender); - ctx.db.game_emote_event().insert(EmoteEvent { + ctx.db.game_emote_event_live().insert(EmoteEvent { id: 0, sender_guid: sender.guid, text_emote, diff --git a/module/src/combat/death.rs b/module/src/combat/death.rs index 12a4155ce..d96beece6 100644 --- a/module/src/combat/death.rs +++ b/module/src/combat/death.rs @@ -3,12 +3,12 @@ use spacetimedb::{table, ReducerContext, Table, TimeDuration}; #[cfg(feature = "debug_reducers")] use spacetimedb::ScheduleAt; +#[cfg(feature = "debug_reducers")] +use crate::game_corpse_loot; use crate::{ game_aura, game_creature_spawn, game_creature_template, game_threat, game_world_entity, WorldEntity, }; -#[cfg(feature = "debug_reducers")] -use crate::{game_corpse_loot, game_spell_cast_event}; pub(crate) const RESPAWN_MICROS: i64 = 15_000_000; pub(crate) const CORPSE_DECAY_MICROS: i64 = 60_000_000; @@ -296,48 +296,6 @@ pub fn debug_set_lethal_damage_floor_health_fixture( Ok(()) } -#[cfg(feature = "debug_reducers")] -#[spacetimedb::reducer] -pub fn debug_verify_ranged_lethal_damage_floor_fixture( - ctx: &ReducerContext, - attacker_guid: u64, - creature_guid: u64, - expected_health: u32, - expected_damage_log: u32, -) -> Result<(), String> { - let creature = ctx - .db - .game_world_entity() - .guid() - .find(creature_guid) - .ok_or_else(|| "fixture ranged target is absent".to_string())?; - if creature.health != expected_health { - return Err(format!( - "fixture ranged health mismatch: actual={} expected={expected_health}", - creature.health - )); - } - let damage = ctx - .db - .game_spell_cast_event() - .iter() - .filter(|event| { - event.caster_guid == attacker_guid - && event.target_guid == creature_guid - && event.spell_id == 75 - && event.is_proc_log - }) - .map(|event| event.damage) - .max() - .unwrap_or(0); - if damage != expected_damage_log { - return Err(format!( - "fixture ranged damage log mismatch: actual={damage} expected={expected_damage_log}" - )); - } - Ok(()) -} - #[cfg(feature = "debug_reducers")] #[spacetimedb::reducer] pub fn debug_apply_lethal_damage_floor_fixture( diff --git a/module/src/combat/engage.rs b/module/src/combat/engage.rs index 497f91015..c1e9e298a 100644 --- a/module/src/combat/engage.rs +++ b/module/src/combat/engage.rs @@ -274,6 +274,7 @@ pub const SWING_ERROR_BAD_FACING: u8 = 2; public, index(accessor = by_grid, btree(columns = [map_id, instance_id, grid_x, grid_y])) )] +#[table(accessor = game_combat_event_live, public, event)] pub struct CombatEvent { #[primary_key] #[auto_inc] diff --git a/module/src/combat/mod.rs b/module/src/combat/mod.rs index 81e7ba5a3..4e6ce73a6 100644 --- a/module/src/combat/mod.rs +++ b/module/src/combat/mod.rs @@ -22,8 +22,9 @@ pub(crate) use engage::{ validate_attack_target, SWING_ERROR_BAD_FACING, SWING_ERROR_NONE, SWING_ERROR_NOT_IN_RANGE, }; pub use engage::{ - game_combat_event, game_melee_attack, game_melee_schedule, game_ranged_impact_schedule, - AttackStart, CombatEvent, MeleeAttack, MeleeSchedule, RangedImpactSchedule, + game_combat_event, game_combat_event_live, game_melee_attack, game_melee_schedule, + game_ranged_impact_schedule, AttackStart, CombatEvent, MeleeAttack, MeleeSchedule, + RangedImpactSchedule, }; pub(crate) use folds::{ arm_react, aura_attack_power_bonus, clear_queued_strike, effective_agility, effective_crit_bp, diff --git a/module/src/combat/swing.rs b/module/src/combat/swing.rs index 3d6ef9090..5d32bd265 100644 --- a/module/src/combat/swing.rs +++ b/module/src/combat/swing.rs @@ -2,8 +2,8 @@ use spacetimedb::{reducer, ReducerContext, ScheduleAt, Table, TimeDuration}; use crate::spell::SpellCastEventKind; use crate::{ - game_item_instance, game_item_template, game_spell, game_spell_cast_event, game_world_entity, - SpellCastEvent, WorldEntity, + game_item_instance, game_item_template, game_spell, game_spell_cast_event_live, + game_world_entity, SpellCastEvent, WorldEntity, }; use super::*; @@ -152,7 +152,7 @@ fn resolve_offhand_swing( let damage = final_damage(ctx, target_guid, dmg); let killing_blow = is_killing_blow(ctx, attacker_guid, &target, damage.amount); - ctx.db.game_combat_event().insert(CombatEvent { + ctx.db.game_combat_event_live().insert(CombatEvent { damage: damage.amount, hit_info, killing_blow, @@ -412,7 +412,7 @@ fn fire_melee_swing( let attacker_guid = attacker.guid; let target_guid = target.guid; let entities = ctx.db.game_world_entity(); - let events = ctx.db.game_combat_event(); + let events = ctx.db.game_combat_event_live(); let melee = ctx.db.game_melee_attack(); let (mut rolled, hit_info, blocked) = roll_swing(ctx, attacker, target); @@ -446,7 +446,7 @@ fn fire_melee_swing( entities.guid().update(a); swing_is_spell = true; } else { - ctx.db.game_spell_cast_event().insert(SpellCastEvent { + ctx.db.game_spell_cast_event_live().insert(SpellCastEvent { is_interrupted: true, failure_reason: crate::spell::CAST_FAIL_NO_POWER, ..SpellCastEvent::signal_at( @@ -486,7 +486,7 @@ fn fire_melee_swing( ..CombatEvent::signal_at(ctx, attacker, target_guid) }); if swing_is_spell { - ctx.db.game_spell_cast_event().insert(SpellCastEvent { + ctx.db.game_spell_cast_event_live().insert(SpellCastEvent { target_guid, is_completion: true, damage: if queued_fired { projected_swing } else { 0 }, @@ -496,7 +496,7 @@ fn fire_melee_swing( }); } if projected_seal > 0 { - ctx.db.game_spell_cast_event().insert(SpellCastEvent { + ctx.db.game_spell_cast_event_live().insert(SpellCastEvent { target_guid, damage: projected_seal, school: 1, @@ -620,7 +620,7 @@ fn fire_ranged_shot( AUTO_SHOT_PROJECTILE_SPEED }; let travel_ms = crate::spell::projectile_travel_ms(dist_sq.sqrt(), speed); - ctx.db.game_combat_event().insert(CombatEvent { + ctx.db.game_combat_event_live().insert(CombatEvent { damage: 0, hit_info, ranged_spell_id, @@ -680,7 +680,7 @@ pub fn ranged_impact(ctx: &ReducerContext, shot: RangedImpactSchedule) { Hit::weapon(HitSource::Ranged, false), ); if damage.amount > 0 && shot.ranged_spell_id != 0 { - ctx.db.game_spell_cast_event().insert(SpellCastEvent { + ctx.db.game_spell_cast_event_live().insert(SpellCastEvent { target_guid: shot.target_guid, damage: damage.amount, is_crit: shot.is_crit, diff --git a/module/src/gc.rs b/module/src/gc.rs index 66b4ade64..d3085fcaf 100644 --- a/module/src/gc.rs +++ b/module/src/gc.rs @@ -44,6 +44,7 @@ pub fn reap_movement_events(ctx: &ReducerContext, _schedule: EventReaperSchedule } }}; } + // Legacy event tables remain for additive upgrades; see docs/danger-zones.md. reap!(game_combat_event); reap!(game_xp_event); reap!(game_levelup_event); diff --git a/module/src/lib.rs b/module/src/lib.rs index 4e543dcb2..6b8ba3f46 100644 --- a/module/src/lib.rs +++ b/module/src/lib.rs @@ -298,9 +298,10 @@ pub use tables::{ game_aura, game_aura_schedule, game_createinfo_spell, game_dynamic_object, game_ground_area, game_ground_area_schedule, game_pending_cast, game_pending_spell_impact, game_player_spell, game_resurrect_request, game_school_lockout, game_self_resurrect_option, game_spell, - game_spell_cast_event, game_spell_cd, game_spell_chain, game_spell_cooldown, game_spell_effect, - game_spell_impact_event, game_spell_learn, game_spell_proc_event, game_spell_reagent, Aura, - AuraSchedule, CreateinfoSpell, DynamicObject, GroundArea, GroundAreaSchedule, PendingCast, + game_spell_cast_event, game_spell_cast_event_live, game_spell_cd, game_spell_chain, + game_spell_cooldown, game_spell_effect, game_spell_impact_event, game_spell_impact_event_live, + game_spell_learn, game_spell_proc_event, game_spell_reagent, Aura, AuraSchedule, + CreateinfoSpell, DynamicObject, GroundArea, GroundAreaSchedule, PendingCast, PendingSpellImpact, PlayerSpell, ResurrectRequest, SchoolLockout, SelfResurrectOption, Spell, SpellCastEvent, SpellCd, SpellChain, SpellCooldown, SpellEffect, SpellImpactEvent, SpellLearn, SpellProcEvent, SpellReagent, @@ -323,9 +324,9 @@ pub use tables::{ }; pub use tables::{ game_channel_event, game_channel_member, game_character_contact, game_chat_event, - game_emote_event, game_roll_event, game_system_message_event, game_whisper_event, ChannelEvent, - ChannelMember, ChatEvent, ContactEntry, EmoteEvent, RollEvent, SystemMessageEvent, - WhisperEvent, + game_chat_event_live, game_emote_event, game_emote_event_live, game_roll_event, + game_system_message_event, game_whisper_event, ChannelEvent, ChannelMember, ChatEvent, + ContactEntry, EmoteEvent, RollEvent, SystemMessageEvent, WhisperEvent, }; pub use tables::{game_character, Character}; pub use tables::{game_character_away, CharacterAway}; @@ -348,8 +349,8 @@ pub use tables::{ }; pub use tables::{game_class_level_stats, game_level_stats, ClassLevelStats, LevelStats}; pub use tables::{ - game_combat_event, game_melee_attack, game_melee_schedule, game_ranged_impact_schedule, - CombatEvent, MeleeAttack, MeleeSchedule, RangedImpactSchedule, + game_combat_event, game_combat_event_live, game_melee_attack, game_melee_schedule, + game_ranged_impact_schedule, CombatEvent, MeleeAttack, MeleeSchedule, RangedImpactSchedule, }; pub use tables::{game_combo_point, ComboPoint}; pub use tables::{game_corpse, Corpse}; diff --git a/module/src/spell/cast/resolve.rs b/module/src/spell/cast/resolve.rs index 686b0567d..e6877da27 100644 --- a/module/src/spell/cast/resolve.rs +++ b/module/src/spell/cast/resolve.rs @@ -317,7 +317,7 @@ fn run_spell_effects( } let school_index = crate::spell::school_index(hdr.school_mask); let (is_completion, client_initiated) = origin.cast_event_flags(); - ctx.db.game_spell_cast_event().insert(SpellCastEvent { + ctx.db.game_spell_cast_event_live().insert(SpellCastEvent { target_guid, is_completion, damage: total_damage, @@ -1239,7 +1239,7 @@ pub(crate) fn begin_cast_with_admission( dest_z: dz, }); start_global_cooldown(ctx, caster_guid, hdr.gcd_ms); - ctx.db.game_spell_cast_event().insert(SpellCastEvent { + ctx.db.game_spell_cast_event_live().insert(SpellCastEvent { target_guid, cast_time_ms: completion_ms, ..SpellCastEvent::signal(ctx, caster_guid, spell_id, SpellCastEventKind::Start) diff --git a/module/src/spell/cast/targeting.rs b/module/src/spell/cast/targeting.rs index 1ba45d14b..a3c5fa814 100644 --- a/module/src/spell/cast/targeting.rs +++ b/module/src/spell/cast/targeting.rs @@ -408,7 +408,7 @@ pub(crate) fn aura_apply( tracer_spell::AURA_SLOTS, hdr.spell_id ); - ctx.db.game_spell_cast_event().insert(SpellCastEvent { + ctx.db.game_spell_cast_event_live().insert(SpellCastEvent { is_interrupted: true, ..SpellCastEvent::signal( ctx, diff --git a/module/src/spell/control.rs b/module/src/spell/control.rs index 2df79948e..e67b2ab54 100644 --- a/module/src/spell/control.rs +++ b/module/src/spell/control.rs @@ -309,7 +309,7 @@ pub(crate) fn pushback_cast(ctx: &ReducerContext, caster_guid: u64) { let new_count = p.pushback_count; p.scheduled_at = ScheduleAt::Time(new_fire_at); pending.scheduled_id().update(p); - ctx.db.game_spell_cast_event().insert(SpellCastEvent { + ctx.db.game_spell_cast_event_live().insert(SpellCastEvent { delay_ms: CAST_PUSHBACK_MS as u32, ..SpellCastEvent::signal(ctx, caster_guid, spell_id, SpellCastEventKind::Pushback) }); @@ -352,7 +352,7 @@ fn interrupt_cast_with_outcome( for (id, spell_id, target_guid) in hits { pending.scheduled_id().delete(id); clear_dead_callback_cast_admission(ctx, caster_guid, spell_id); - ctx.db.game_spell_cast_event().insert(SpellCastEvent { + ctx.db.game_spell_cast_event_live().insert(SpellCastEvent { is_interrupted: true, ..SpellCastEvent::signal(ctx, caster_guid, spell_id, SpellCastEventKind::Interrupt) }); diff --git a/module/src/spell/mod.rs b/module/src/spell/mod.rs index 719ab3ff3..27cd4ac97 100644 --- a/module/src/spell/mod.rs +++ b/module/src/spell/mod.rs @@ -91,12 +91,13 @@ pub use tables::{ game_aura, game_aura_schedule, game_dynamic_object, game_ground_area, game_ground_area_schedule, game_pending_cast, game_pending_spell_impact, game_resurrect_request, game_school_lockout, game_self_resurrect_option, game_spell, - game_spell_cast_event, game_spell_cd, game_spell_cooldown, game_spell_effect, - game_spell_impact_event, game_spell_proc_event, game_spell_reagent, Aura, AuraSchedule, - DynamicObject, GroundArea, GroundAreaSchedule, PendingCast, PendingSpellImpact, - ResurrectRequest, SchoolLockout, SelfResurrectOption, Spell, SpellCastEvent, SpellCd, - SpellCooldown, SpellEffect, SpellImpactEvent, SpellProcEvent, SpellReagent, CAST_FAIL_NO_POWER, - CAST_MISS_MISS, CAST_MISS_NONE, CAST_MISS_RESIST, + game_spell_cast_event, game_spell_cast_event_live, game_spell_cd, game_spell_cooldown, + game_spell_effect, game_spell_impact_event, game_spell_impact_event_live, + game_spell_proc_event, game_spell_reagent, Aura, AuraSchedule, DynamicObject, GroundArea, + GroundAreaSchedule, PendingCast, PendingSpellImpact, ResurrectRequest, SchoolLockout, + SelfResurrectOption, Spell, SpellCastEvent, SpellCd, SpellCooldown, SpellEffect, + SpellImpactEvent, SpellProcEvent, SpellReagent, CAST_FAIL_NO_POWER, CAST_MISS_MISS, + CAST_MISS_NONE, CAST_MISS_RESIST, }; pub(crate) use lyracore_shared::spell::taxonomy::{ diff --git a/module/src/spell/proc.rs b/module/src/spell/proc.rs index 1cc5fd9c0..e3fe19a63 100644 --- a/module/src/spell/proc.rs +++ b/module/src/spell/proc.rs @@ -396,7 +396,7 @@ fn fire_proc_damage(ctx: &ReducerContext, row: &Aura, counterparty_guid: u64) -> Hit::spell(HitSource::Triggered, row.spell_id, false), ); if dealt > 0 || absorbed > 0 { - ctx.db.game_spell_cast_event().insert(SpellCastEvent { + ctx.db.game_spell_cast_event_live().insert(SpellCastEvent { target_guid: counterparty_guid, damage: dealt, school: school_index(school_mask), diff --git a/module/src/spell/scheduler.rs b/module/src/spell/scheduler.rs index 64d7673f2..e521816ad 100644 --- a/module/src/spell/scheduler.rs +++ b/module/src/spell/scheduler.rs @@ -39,7 +39,7 @@ pub(crate) fn fire_pending_cast(ctx: &ReducerContext, sched: PendingCast) { e ); // A failed completion needs SMSG_SPELL_FAILURE to clear the filled client cast bar. - ctx.db.game_spell_cast_event().insert(SpellCastEvent { + ctx.db.game_spell_cast_event_live().insert(SpellCastEvent { is_interrupted: true, ..SpellCastEvent::signal( ctx, @@ -84,22 +84,24 @@ pub fn fire_spell_impact(ctx: &ReducerContext, sched: PendingSpellImpact) { ), ); let (map_id, instance_id, grid_x, grid_y) = crate::helpers::grid_of(ctx, sched.caster_guid); - ctx.db.game_spell_impact_event().insert(SpellImpactEvent { - id: 0, - caster_guid: sched.caster_guid, - target_guid: sched.target_guid, - spell_id: sched.spell_id, - created_at: ctx.timestamp, - damage: dealt, - school: sched.school_index, - is_crit: sched.is_crit, - resisted: sched.resisted, - absorbed, - map_id, - instance_id, - grid_x, - grid_y, - }); + ctx.db + .game_spell_impact_event_live() + .insert(SpellImpactEvent { + id: 0, + caster_guid: sched.caster_guid, + target_guid: sched.target_guid, + spell_id: sched.spell_id, + created_at: ctx.timestamp, + damage: dealt, + school: sched.school_index, + is_crit: sched.is_crit, + resisted: sched.resisted, + absorbed, + map_id, + instance_id, + grid_x, + grid_y, + }); } pub(crate) fn do_cast_spell( @@ -338,7 +340,7 @@ pub fn tick_auras(ctx: &ReducerContext, _schedule: AuraSchedule) { .unwrap_or(0); let amount = resist_magic_damage(ctx, a.target_guid, school, a.level as u32, a.amount); - ctx.db.game_spell_cast_event().insert(SpellCastEvent { + ctx.db.game_spell_cast_event_live().insert(SpellCastEvent { damage: amount.max(0) as u32, school: crate::spell::school_index(school), resisted: a.amount.saturating_sub(amount).max(0) as u32, @@ -398,7 +400,7 @@ pub fn tick_auras(ctx: &ReducerContext, _schedule: AuraSchedule) { let new_health = healed_value(cur, target.max_health, a.amount); pending.insert(a.target_guid, new_health); if cur < target.max_health { - ctx.db.game_spell_cast_event().insert(SpellCastEvent { + ctx.db.game_spell_cast_event_live().insert(SpellCastEvent { healed: a.amount.max(0) as u32, ..SpellCastEvent::periodic_log(ctx, a.caster_guid, a.spell_id, &target) }); @@ -415,7 +417,7 @@ pub fn tick_auras(ctx: &ReducerContext, _schedule: AuraSchedule) { a.target_guid, energized_value(cur, target.max_power, a.amount), ); - ctx.db.game_spell_cast_event().insert(SpellCastEvent { + ctx.db.game_spell_cast_event_live().insert(SpellCastEvent { energized: a.amount.max(0) as u32, power_type: target.power_type(), ..SpellCastEvent::periodic_log(ctx, a.caster_guid, a.spell_id, &target) @@ -625,7 +627,7 @@ pub fn tick_ground_areas(ctx: &ReducerContext, _schedule: GroundAreaSchedule) { ); if dealt > 0 || absorbed > 0 { ctx.db - .game_spell_cast_event() + .game_spell_cast_event_live() .insert(crate::spell::SpellCastEvent { target_guid: t, damage: dealt, diff --git a/module/src/spell/tables.rs b/module/src/spell/tables.rs index f46d3f48a..057040bb8 100644 --- a/module/src/spell/tables.rs +++ b/module/src/spell/tables.rs @@ -199,6 +199,7 @@ pub(crate) enum SpellCastEventKind { public, index(accessor = by_grid, btree(columns = [map_id, instance_id, grid_x, grid_y])) )] +#[table(accessor = game_spell_cast_event_live, public, event)] pub struct SpellCastEvent { #[primary_key] #[auto_inc] @@ -489,6 +490,7 @@ pub struct PendingSpellImpact { public, index(accessor = by_grid, btree(columns = [map_id, instance_id, grid_x, grid_y])) )] +#[table(accessor = game_spell_impact_event_live, public, event)] pub struct SpellImpactEvent { #[primary_key] #[auto_inc] diff --git a/module/src/tables.rs b/module/src/tables.rs index 6214edfee..53c8b29fb 100644 --- a/module/src/tables.rs +++ b/module/src/tables.rs @@ -32,14 +32,14 @@ pub use crate::channel::{ pub use crate::character::{game_character, Character}; pub use crate::chat::{ game_channel_event, game_channel_member, game_character_contact, game_chat_event, - game_emote_event, game_roll_event, game_system_message_event, game_whisper_event, ChannelEvent, - ChannelMember, ChatEvent, ContactEntry, EmoteEvent, RollEvent, SystemMessageEvent, - WhisperEvent, + game_chat_event_live, game_emote_event, game_emote_event_live, game_roll_event, + game_system_message_event, game_whisper_event, ChannelEvent, ChannelMember, ChatEvent, + ContactEntry, EmoteEvent, RollEvent, SystemMessageEvent, WhisperEvent, }; pub use crate::combat::death::{game_creature_lethal_damage_floor, CreatureLethalDamageFloor}; pub use crate::combat::{ - game_combat_event, game_melee_attack, game_melee_schedule, game_ranged_impact_schedule, - CombatEvent, MeleeAttack, MeleeSchedule, RangedImpactSchedule, + game_combat_event, game_combat_event_live, game_melee_attack, game_melee_schedule, + game_ranged_impact_schedule, CombatEvent, MeleeAttack, MeleeSchedule, RangedImpactSchedule, }; pub use crate::combo::{game_combo_point, ComboPoint}; pub use crate::config::{ @@ -200,9 +200,10 @@ pub use crate::spell::{ game_aura, game_aura_schedule, game_createinfo_spell, game_dynamic_object, game_ground_area, game_ground_area_schedule, game_pending_cast, game_pending_spell_impact, game_player_spell, game_resurrect_request, game_school_lockout, game_self_resurrect_option, game_spell, - game_spell_cast_event, game_spell_cd, game_spell_chain, game_spell_cooldown, game_spell_effect, - game_spell_impact_event, game_spell_learn, game_spell_proc_event, game_spell_reagent, Aura, - AuraSchedule, CreateinfoSpell, DynamicObject, GroundArea, GroundAreaSchedule, PendingCast, + game_spell_cast_event, game_spell_cast_event_live, game_spell_cd, game_spell_chain, + game_spell_cooldown, game_spell_effect, game_spell_impact_event, game_spell_impact_event_live, + game_spell_learn, game_spell_proc_event, game_spell_reagent, Aura, AuraSchedule, + CreateinfoSpell, DynamicObject, GroundArea, GroundAreaSchedule, PendingCast, PendingSpellImpact, PlayerSpell, ResurrectRequest, SchoolLockout, SelfResurrectOption, Spell, SpellCastEvent, SpellCd, SpellChain, SpellCooldown, SpellEffect, SpellImpactEvent, SpellLearn, SpellProcEvent, SpellReagent, diff --git a/module/src/tripwires.rs b/module/src/tripwires.rs index 147080b44..089c0e94f 100644 --- a/module/src/tripwires.rs +++ b/module/src/tripwires.rs @@ -43,6 +43,16 @@ pub(crate) mod character_owned_tripwire { &["game_party_command_intent"], "source Module command relay retained through issuer Transfer until Gateway finalization or event GC", ), + ( + &[ + "game_chat_event_live", + "game_combat_event_live", + "game_emote_event_live", + "game_spell_cast_event_live", + "game_spell_impact_event_live", + ], + "native Relay events discarded after their creating transaction", + ), ( &[ "game_auction_notice", @@ -402,18 +412,32 @@ pub(crate) mod character_owned_tripwire { String::from_utf8(code).expect("masking Rust source preserves UTF-8") } - pub(super) fn extract_tables(content: &str) -> Vec<(String, String)> { + pub(super) fn extract_tables(content: &str) -> Vec<(String, String, bool)> { let code = production_code(content); let mut out = Vec::new(); let mut search_from = 0usize; while let Some(rel) = code[search_from..].find("#[table(") { let attr_start = search_from + rel; let attr_rest = &code[attr_start..]; - let accessor = attr_rest + let attr_end = attr_rest.find(")]").expect("table attribute must close"); + let attributes = &attr_rest["#[table(".len()..attr_end]; + let mut depth = 0i32; + let is_event = attributes.split(',').any(|argument| { + let top_level = depth == 0; + for byte in argument.bytes() { + match byte { + b'(' | b'[' => depth += 1, + b')' | b']' => depth -= 1, + _ => {} + } + } + top_level && argument.trim() == "event" + }); + let accessor = attributes .find("accessor") - .and_then(|i| attr_rest[i..].find('=').map(|eq| i + eq + 1)) + .and_then(|i| attributes[i..].find('=').map(|eq| i + eq + 1)) .map(|name_start| { - let name_rest = attr_rest[name_start..].trim_start(); + let name_rest = attributes[name_start..].trim_start(); let end = name_rest .find(|c: char| !(c.is_alphanumeric() || c == '_')) .unwrap_or(name_rest.len()); @@ -449,7 +473,7 @@ pub(crate) mod character_owned_tripwire { let body_end = body_end .unwrap_or_else(|| panic!("unterminated struct body for accessor {accessor}")); let fields = after_struct[body_start..=body_end].to_string(); - out.push((accessor, fields)); + out.push((accessor, fields, is_event)); search_from = attr_start + "#[table(".len(); } out @@ -535,7 +559,7 @@ pub(crate) mod character_owned_tripwire { let markers = character_owned_markers(content); extract_tables(content) .into_iter() - .filter_map(|(accessor, fields)| { + .filter_map(|(accessor, fields, _)| { let guids = character_capable_guid_fields(&fields); if guids.is_empty() || NOT_CHARACTER_OWNED @@ -730,7 +754,7 @@ pub(crate) mod character_owned_tripwire { } let content = fs::read_to_string(&file) .unwrap_or_else(|error| panic!("cannot read {}: {error}", file.display())); - for (accessor, fields) in extract_tables(&content) { + for (accessor, fields, _) in extract_tables(&content) { if !character_capable_guid_fields(&fields).is_empty() { candidates.push(accessor); } @@ -1251,12 +1275,13 @@ mod gc_reap_tripwire { let gc_code = super::character_owned_tripwire::production_code(gc_src); super::character_owned_tripwire::extract_tables(content) .into_iter() - .filter(|(accessor, fields)| { - is_ttl_shaped(fields) + .filter(|(accessor, fields, is_event)| { + !is_event + && is_ttl_shaped(fields) && !EXEMPT_ACCESSORS.contains(&accessor.as_str()) && !gc_reaps(&gc_code, accessor) }) - .map(|(accessor, _)| accessor) + .map(|(accessor, _, _)| accessor) .collect() } @@ -1279,7 +1304,7 @@ mod gc_reap_tripwire { assert!( missing.is_empty(), - "Invariant: every TTL-shaped event table is reaped in gc.rs.\n\ + "Invariant: every ordinary TTL-shaped event table is reaped in gc.rs.\n\ table(s) shaped `id: u64` PK + `created_at: Timestamp` — the TTL-reaped convention \ every existing short-lived event table uses — with no reap in gc.rs: {missing:?}\n\n\ A short-lived event table only stops growing without bound if something deletes its \ @@ -1306,6 +1331,26 @@ mod gc_reap_tripwire { let reaped = "fn reap(ctx: &ReducerContext) {\n reap!(game_fixture_event);\n}\n"; assert!(unreaped_tables(table, reaped).is_empty()); } + + #[test] + fn only_a_native_event_attribute_exempts_a_table_from_gc() { + let source = r#" + #[table(accessor = game_legacy_event, public, + index(accessor = by_payload, btree(columns = [id, event, created_at])))] + #[table(accessor = game_native_event, public, event)] + pub struct Notification { + pub id: u64, + pub event: u8, + pub created_at: Timestamp, + } + "#; + assert_eq!(unreaped_tables(source, ""), ["game_legacy_event"]); + let ordinary = source.replace("public, event)", "public)"); + assert_eq!( + unreaped_tables(&ordinary, ""), + ["game_legacy_event", "game_native_event"] + ); + } } #[cfg(test)] diff --git a/module/tests/cast_global_cooldown.rs b/module/tests/cast_global_cooldown.rs index f20e691cd..4216a9454 100644 --- a/module/tests/cast_global_cooldown.rs +++ b/module/tests/cast_global_cooldown.rs @@ -1,7 +1,9 @@ mod support; use spacetimedb::Timestamp; -use support::{poll_until, Row, Standalone, POLL_TIMEOUT}; +use support::{ + event_rows, poll_until, wait_for_event_rows, Row, Standalone, TableSubscription, POLL_TIMEOUT, +}; const WOLF: u64 = (0xF130_u64 << 48) | (51000_u64 << 24) | 1; const ACTOR: &str = r#"{"guid":1,"ownership":null}"#; @@ -10,8 +12,11 @@ const ACTOR: &str = r#"{"guid":1,"ownership":null}"#; #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn fireball_can_be_cast_again_when_its_cast_bar_finishes() { let shard = fixture("fireball-global-cooldown", 2000, 1500, 0); + let casts = shard.subscribe(&[ + "SELECT * FROM game_spell_cast_event_live WHERE caster_guid = 1 AND spell_id = 133", + ]); cast_fireball(&shard); - let completed_at = timestamp(&completion(&shard), "created_at"); + let completed_at = timestamp(&completion(&casts), "created_at"); let ready_at = timestamp(&global_cooldown(&shard), "ready_at"); assert!( ready_at <= completed_at, @@ -30,9 +35,15 @@ fn fireball_can_be_cast_again_when_its_cast_bar_finishes() { #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn a_short_cast_finishes_during_its_original_global_cooldown() { let shard = fixture("short-cast-global-cooldown", 500, 5000, 7000); + let casts = shard.subscribe(&[ + "SELECT * FROM game_spell_cast_event_live WHERE caster_guid = 1 AND spell_id = 133", + ]); cast_fireball(&shard); let started_at = timestamp( - &shard.query_rows("SELECT * FROM game_spell_cast_event WHERE cast_time_ms = 500")[0], + wait_for_event_rows(&casts, "game_spell_cast_event_live") + .iter() + .find(|row| row["cast_time_ms"] == "500") + .expect("the timed cast starts"), "created_at", ); let original_cooldown = global_cooldown(&shard); @@ -40,7 +51,7 @@ fn a_short_cast_finishes_during_its_original_global_cooldown() { timestamp(&original_cooldown, "ready_at"), started_at + 5_000_000 ); - let completed_at = timestamp(&completion(&shard), "created_at"); + let completed_at = timestamp(&completion(&casts), "created_at"); assert!(completed_at < timestamp(&original_cooldown, "ready_at")); assert_eq!(global_cooldown(&shard), original_cooldown); let spell_cooldown = shard.query_rows("SELECT * FROM game_spell_cd WHERE caster_guid = 1"); @@ -55,9 +66,12 @@ fn a_short_cast_finishes_during_its_original_global_cooldown() { #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn an_instant_cast_still_starts_a_global_cooldown() { let shard = fixture("instant-cast-global-cooldown", 0, 5000, 0); + let casts = shard.subscribe(&[ + "SELECT * FROM game_spell_cast_event_live WHERE caster_guid = 1 AND spell_id = 133", + ]); cast_fireball(&shard); let launched_at = timestamp( - &shard.query_rows("SELECT * FROM game_spell_cast_event WHERE caster_guid = 1")[0], + &wait_for_event_rows(&casts, "game_spell_cast_event_live")[0], "created_at", ); assert_eq!( @@ -121,12 +135,14 @@ fn cast_fireball(shard: &Standalone) { shard.assert_call("gw_cast_spell", &[ACTOR, "133", &WOLF.to_string()]); } -fn completion(shard: &Standalone) -> Row { +fn completion(casts: &TableSubscription) -> Row { let mut rows = Vec::new(); assert!( poll_until(POLL_TIMEOUT, || { - rows = - shard.query_rows("SELECT * FROM game_spell_cast_event WHERE is_completion = true"); + rows = event_rows(casts, "game_spell_cast_event_live") + .into_iter() + .filter(|row| row["is_completion"] == "true") + .collect(); !rows.is_empty() }), "the timed cast did not launch" diff --git a/module/tests/combat_log.rs b/module/tests/combat_log.rs index f4c41e3f5..e9e2f202a 100644 --- a/module/tests/combat_log.rs +++ b/module/tests/combat_log.rs @@ -6,7 +6,10 @@ use lyracore_shared::spell::taxonomy::{ A_PERIODIC_DAMAGE, E_DAMAGE, E_ENERGIZE, T_SELF, T_TARGET_ENEMY, }; -use support::{number, poll_until, Row, Standalone, POLL_TIMEOUT}; +use support::{ + event_rows, number, poll_until, wait_for_event_rows, Row, Standalone, TableSubscription, + POLL_TIMEOUT, +}; const CHARACTER: u64 = 1; const WOLF: u64 = (0xF130_u64 << 48) | (51_000_u64 << 24) | 1; @@ -54,6 +57,9 @@ fn damage_heal_and_energize_ticks_each_log_their_outcome() { "UPDATE game_aura SET eff_kind = {kind}, amount = {amount} WHERE spell_id = {spell_id}" )); } + let events = standalone.subscribe(&[&format!( + "SELECT * FROM game_spell_cast_event_live WHERE kind = {PERIODIC_LOG}" + )]); let due = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() @@ -64,11 +70,11 @@ fn damage_heal_and_energize_ticks_each_log_their_outcome() { )); assert!( - poll_until(POLL_TIMEOUT, || logs(&standalone).len() >= 3), + poll_until(POLL_TIMEOUT, || logs(&events).len() >= 3), "every tick must leave a log, got {:?}", - logs(&standalone) + logs(&events) ); - let logs = logs(&standalone); + let logs = logs(&events); let log = |spell_id: u32| -> &Row { logs.iter() .find(|row| number::(row, "spell_id") == spell_id) @@ -96,11 +102,8 @@ fn damage_heal_and_energize_ticks_each_log_their_outcome() { assert_eq!(number::(energize, "power_type"), power_type); } -fn logs(standalone: &Standalone) -> Vec { - standalone.query_rows(&format!( - "SELECT spell_id, caster_guid, target_guid, damage, healed, energized, power_type \ - FROM game_spell_cast_event WHERE kind = {PERIODIC_LOG}" - )) +fn logs(events: &TableSubscription) -> Vec { + event_rows(events, "game_spell_cast_event_live") } const SELF_ENERGIZE: u32 = 509_0101; @@ -143,6 +146,9 @@ fn a_direct_energize_records_the_power_given_on_the_go() { .expect("the Character must be live"); let power_type = (number::(&character, "unit_bytes_0") >> 24) & 0xFF; + let events = standalone.subscribe(&[&format!( + "SELECT * FROM game_spell_cast_event_live WHERE spell_id = {SELF_ENERGIZE} AND kind = 2" + )]); standalone.assert_call( "debug_cast_at", &[ @@ -152,11 +158,7 @@ fn a_direct_energize_records_the_power_given_on_the_go() { ], ); - let go = standalone - .query_rows(&format!( - "SELECT energized, power_type FROM game_spell_cast_event \ - WHERE spell_id = {SELF_ENERGIZE} AND kind = 2" - )) + let go = wait_for_event_rows(&events, "game_spell_cast_event_live") .pop() .expect("the energize must report its GO"); let power: u32 = number( @@ -228,10 +230,12 @@ fn a_resisted_spell_lands_none_of_its_effects_and_reports_the_resist() { let mut missed = None; for _ in 0..10 { - standalone.assert_sql("DELETE FROM game_spell_cast_event"); standalone.assert_sql(&format!( "DELETE FROM game_aura WHERE spell_id = {FIRE_WITH_DOT}" )); + let events = standalone.subscribe(&[&format!( + "SELECT * FROM game_spell_cast_event_live WHERE spell_id = {FIRE_WITH_DOT} AND kind = 2" + )]); standalone.assert_call( "debug_cast_at", &[ @@ -240,11 +244,7 @@ fn a_resisted_spell_lands_none_of_its_effects_and_reports_the_resist() { &wolf.to_string(), ], ); - let go = standalone - .query_rows(&format!( - "SELECT miss_info, damage FROM game_spell_cast_event \ - WHERE spell_id = {FIRE_WITH_DOT} AND kind = 2" - )) + let go = wait_for_event_rows(&events, "game_spell_cast_event_live") .pop() .expect("the cast must report its GO"); if number::(&go, "miss_info") != 0 { diff --git a/module/tests/duel_killing_blow.rs b/module/tests/duel_killing_blow.rs index 225d8c6b0..d96f9662b 100644 --- a/module/tests/duel_killing_blow.rs +++ b/module/tests/duel_killing_blow.rs @@ -1,6 +1,6 @@ mod support; -use support::{number, poll_until, Standalone, POLL_TIMEOUT}; +use support::{number, poll_until, wait_for_event_rows, Standalone, POLL_TIMEOUT}; const PLAYER: u64 = 1; const DUEL: u64 = 509_0001; @@ -57,6 +57,10 @@ fn a_duel_ending_swing_is_no_killing_blow() { player["map_id"], player["instance_id"] )); + let events = standalone.subscribe(&[ + &format!("SELECT * FROM game_combat_event_live WHERE attacker_guid = {PLAYER}"), + &format!("SELECT * FROM game_duel WHERE id = {DUEL}"), + ]); standalone.assert_call("debug_engage", &[&PLAYER.to_string(), &rival.to_string()]); // A swing may miss or be avoided, so keep making the next one due. assert!( @@ -64,9 +68,10 @@ fn a_duel_ending_swing_is_no_killing_blow() { standalone.assert_sql(&format!( "UPDATE game_melee_attack SET last_swing_ms = 1 WHERE attacker_guid = {PLAYER}" )); - standalone - .query_rows(&format!("SELECT id FROM game_duel WHERE id = {DUEL}")) - .is_empty() + events + .deletes("game_duel") + .iter() + .any(|row| row["id"] == DUEL) }), "no swing ended the Duel" ); @@ -78,9 +83,7 @@ fn a_duel_ending_swing_is_no_killing_blow() { .remove(0); assert_eq!(rival_row["dead"], "false", "the Duel loser must survive"); assert_eq!(rival_row["health"], "1"); - let swings = standalone.query_rows(&format!( - "SELECT killing_blow FROM game_combat_event WHERE attacker_guid = {PLAYER}" - )); + let swings = wait_for_event_rows(&events, "game_combat_event_live"); assert!(!swings.is_empty(), "the Duel must have ended on a swing"); assert!( swings.iter().all(|swing| swing["killing_blow"] == "false"), diff --git a/module/tests/full_world_eventai.rs b/module/tests/full_world_eventai.rs index 79f188f48..01ab06a18 100644 --- a/module/tests/full_world_eventai.rs +++ b/module/tests/full_world_eventai.rs @@ -1,7 +1,7 @@ mod support; use std::time::{Duration, Instant}; -use support::{poll_until, Standalone, POLL_TIMEOUT}; +use support::{event_rows, poll_until, Standalone, POLL_TIMEOUT}; const ENTRY: u32 = 51_000; const CREATURE: u64 = (0xF130_u64 << 48) | ((ENTRY as u64) << 24) | 1; @@ -48,6 +48,9 @@ fn zone_speech_resolves_terrain_imported_after_the_creature_spawn() { ], ); shard.assert_sql("INSERT INTO game_creature_ai_broadcast_text (id,male_text,female_text,chat_type,language_id,emote_delay_1_ms,emote_id_1,emote_delay_2_ms,emote_id_2,emote_delay_3_ms,emote_id_3) VALUES (5099000,'Zone speech','',6,0,0,0,0,0,0,0)"); + let speech = shard.subscribe(&[&format!( + "SELECT * FROM game_chat_event_live WHERE sender_guid = {CREATURE} AND chat_type = 6" + )]); let rules = "900003,health:0:100,100,4294967295,repeat:500:500,all,ordinary,any-posture,auto-attack:0+speak:zone-yell:self:5099000"; shard.assert_call( "debug_stage_eventai_revision_fixture", @@ -70,11 +73,11 @@ fn zone_speech_resolves_terrain_imported_after_the_creature_spawn() { ))[0]["zone_id"] == zone.to_string())); } - assert!(!shard - .query_rows(&format!( - "SELECT id FROM game_chat_event WHERE sender_guid = {CREATURE} AND chat_type = 6" - )) - .is_empty()); + assert!(poll_until(POLL_TIMEOUT, || !event_rows( + &speech, + "game_chat_event_live" + ) + .is_empty())); } #[test] diff --git a/module/tests/hearthstone.rs b/module/tests/hearthstone.rs index 49dcb11ce..6d1014e4e 100644 --- a/module/tests/hearthstone.rs +++ b/module/tests/hearthstone.rs @@ -2,7 +2,7 @@ mod support; use spacetimedb::Timestamp; use std::time::Duration; -use support::{actor, poll_until, Row, Standalone, POLL_TIMEOUT}; +use support::{actor, event_rows, poll_until, wait_for_event_rows, Row, Standalone, POLL_TIMEOUT}; fn fixture(name: &str) -> Standalone { let mut shard = Standalone::start(name); @@ -39,14 +39,14 @@ fn timestamp(row: &Row) -> i64 { #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn hearthstone_recalls_to_the_bound_home_only_after_ten_seconds() { let shard = fixture("hearthstone-completion"); + let casts = + shard.subscribe(&["SELECT * FROM game_spell_cast_event_live WHERE spell_id = 8690"]); let before = position(&shard); let inventory = items(&shard); use_hearthstone(&shard); assert_eq!(position(&shard), before); assert_eq!(items(&shard), inventory); - let started = shard.query_rows( - "SELECT created_at, cast_time_ms FROM game_spell_cast_event WHERE spell_id = 8690", - ); + let started = wait_for_event_rows(&casts, "game_spell_cast_event_live"); assert_eq!(started.len(), 1); assert_eq!(started[0]["cast_time_ms"], "10000"); assert_eq!( @@ -56,7 +56,10 @@ fn hearthstone_recalls_to_the_bound_home_only_after_ten_seconds() { ); let mut completed = Vec::new(); assert!(poll_until(POLL_TIMEOUT, || { - completed = shard.query_rows("SELECT created_at FROM game_spell_cast_event WHERE spell_id = 8690 AND is_completion = true"); + completed = event_rows(&casts, "game_spell_cast_event_live") + .into_iter() + .filter(|row| row["is_completion"] == "true") + .collect(); !completed.is_empty() })); assert!(timestamp(&completed[0]) - timestamp(&started[0]) >= 10_000_000); diff --git a/module/tests/lethal_damage_floor.rs b/module/tests/lethal_damage_floor.rs index 762eb8a47..b789a1e63 100644 --- a/module/tests/lethal_damage_floor.rs +++ b/module/tests/lethal_damage_floor.rs @@ -1,6 +1,6 @@ mod support; -use support::Standalone; +use support::{event_rows, poll_until, Standalone, POLL_TIMEOUT}; const PLAYER_GUID: u64 = 1; const WOLF_ENTRY: u32 = 51_000; @@ -100,6 +100,10 @@ fn authoritative_damage_honors_the_lethal_floor_and_its_lifetime() { &[&PLAYER_GUID.to_string(), &WOLF_ENTRY.to_string(), "1.0"], ); stage(&standalone, THIRD_WOLF_GUID); + let casts = standalone.subscribe(&[&format!( + "SELECT * FROM game_spell_cast_event_live WHERE caster_guid = {PLAYER_GUID} \ + AND target_guid = {THIRD_WOLF_GUID} AND spell_id = 75 AND is_proc_log = true" + )]); standalone.assert_call( "debug_stage_ranged_lethal_damage_floor_fixture", &[ @@ -113,14 +117,19 @@ fn authoritative_damage_honors_the_lethal_floor_and_its_lifetime() { "debug_set_lethal_damage_floor_health_fixture", &[&THIRD_WOLF_GUID.to_string(), "5"], ); - standalone.wait_until_call_succeeds( - "debug_verify_ranged_lethal_damage_floor_fixture", - &[ - &PLAYER_GUID.to_string(), - &THIRD_WOLF_GUID.to_string(), - "1", - "4", - ], + assert!( + poll_until(POLL_TIMEOUT, || { + event_rows(&casts, "game_spell_cast_event_live") + .iter() + .any(|row| row["damage"] == "4") + }), + "the ranged impact must report the four damage above the lethal floor" + ); + assert_eq!( + standalone.query_rows(&format!( + "SELECT health FROM game_world_entity WHERE guid = {THIRD_WOLF_GUID}" + ))[0]["health"], + "1" ); } diff --git a/module/tests/notification_tables.rs b/module/tests/notification_tables.rs new file mode 100644 index 000000000..e60f42198 --- /dev/null +++ b/module/tests/notification_tables.rs @@ -0,0 +1,34 @@ +use lyracore_module::{ + game_chat_event, game_chat_event_live, game_combat_event, game_combat_event_live, + game_emote_event, game_emote_event_live, game_spell_cast_event, game_spell_cast_event_live, + game_spell_impact_event, game_spell_impact_event_live, +}; +use spacetimedb::{Local, Table}; + +fn additive_event>( + _legacy: fn(&Local) -> &Legacy, + _live: fn(&Local) -> &Live, +) { + assert!( + !Legacy::IS_EVENT, + "{} must stay ordinary", + Legacy::TABLE_NAME + ); + assert!(Live::IS_EVENT, "{} must be transient", Live::TABLE_NAME); + assert_eq!(Live::TABLE_NAME, format!("{}_live", Legacy::TABLE_NAME)); +} + +#[test] +fn native_notifications_keep_the_existing_tables_and_row_types() { + additive_event(Local::game_combat_event, Local::game_combat_event_live); + additive_event( + Local::game_spell_cast_event, + Local::game_spell_cast_event_live, + ); + additive_event( + Local::game_spell_impact_event, + Local::game_spell_impact_event_live, + ); + additive_event(Local::game_chat_event, Local::game_chat_event_live); + additive_event(Local::game_emote_event, Local::game_emote_event_live); +} diff --git a/module/tests/proximity_emotes.rs b/module/tests/proximity_emotes.rs index e83ef99ba..f8bc4b272 100644 --- a/module/tests/proximity_emotes.rs +++ b/module/tests/proximity_emotes.rs @@ -3,7 +3,7 @@ mod support; use serde_json::Value; use support::{actor, Standalone}; -const CHAT_ROWS: &str = "SELECT * FROM game_chat_event"; +const CHAT_ROWS: &str = "SELECT * FROM game_chat_event_live"; const CHARACTER: u64 = 1; fn refused(standalone: &Standalone, args: &[&str], tag: &str) { @@ -20,7 +20,7 @@ fn refused(standalone: &Standalone, args: &[&str], tag: &str) { } fn inserted_rows(update: &Value) -> Vec { - update["game_chat_event"]["inserts"] + update["game_chat_event_live"]["inserts"] .as_array() .cloned() .unwrap_or_default() diff --git a/module/tests/queued_strike_fizzle.rs b/module/tests/queued_strike_fizzle.rs index 2a74ef506..fd26528e8 100644 --- a/module/tests/queued_strike_fizzle.rs +++ b/module/tests/queued_strike_fizzle.rs @@ -5,7 +5,7 @@ use lyracore_shared::spell::taxonomy::{E_NEXT_SWING, T_TARGET_ENEMY}; use std::collections::BTreeMap; use std::time::{SystemTime, UNIX_EPOCH}; -use support::{poll_until, Standalone, POLL_TIMEOUT}; +use support::{event_rows, poll_until, Standalone, TableSubscription, POLL_TIMEOUT}; type SqlRow = BTreeMap; @@ -53,7 +53,7 @@ fn a_queued_strike_fizzles_into_a_white_swing_when_the_rage_is_gone() { "the shout must leave the Warrior short of Heroic Strike's cost, had {rage_at_the_swing}" ); - swing(&standalone); + let (casts, landed) = swing(&standalone); let after = player(&standalone); assert!( @@ -66,7 +66,6 @@ fn a_queued_strike_fizzles_into_a_white_swing_when_the_rage_is_gone() { "0", "the queued strike must be cleared, so the ability can be queued again" ); - let casts = cast_events(&standalone); assert!( casts.iter().any(|row| is_power_failure(row, HEROIC_STRIKE)), "the caster must be told the strike failed for lack of rage, got {casts:?}" @@ -76,8 +75,7 @@ fn a_queued_strike_fizzles_into_a_white_swing_when_the_rage_is_gone() { "a fizzled strike must not report a cast, got {casts:?}" ); assert_eq!( - own_swing(&standalone)["spell_swing"], - "false", + landed["spell_swing"], "false", "the attack must resolve as an ordinary white swing" ); @@ -87,7 +85,7 @@ fn a_queued_strike_fizzles_into_a_white_swing_when_the_rage_is_gone() { stage_power(&standalone, CLEAVE_COST); cast(&standalone, CLEAVE, wolf); - swing(&standalone); + let (casts, landed) = swing(&standalone); let after = player(&standalone); assert_eq!( @@ -96,7 +94,6 @@ fn a_queued_strike_fizzles_into_a_white_swing_when_the_rage_is_gone() { "an exactly affordable strike spends it all" ); assert_eq!(queued_spell(&after), "0"); - let casts = cast_events(&standalone); assert!( casts.iter().any(|row| fired(row, CLEAVE)), "rage equal to the cost must fire the strike, got {casts:?}" @@ -106,15 +103,14 @@ fn a_queued_strike_fizzles_into_a_white_swing_when_the_rage_is_gone() { "an affordable strike must not report a failure, got {casts:?}" ); assert_eq!( - own_swing(&standalone)["spell_swing"], - "true", + landed["spell_swing"], "true", "a fired strike replaces the white swing" ); stage_power(&standalone, CLEAVE_COST); cast(&standalone, CLEAVE, wolf); stage_power(&standalone, CLEAVE_COST - 1); - swing(&standalone); + let (casts, landed) = swing(&standalone); let after = player(&standalone); assert_eq!( @@ -123,7 +119,6 @@ fn a_queued_strike_fizzles_into_a_white_swing_when_the_rage_is_gone() { "a fizzle leaves the pool exactly as it found it" ); assert_eq!(queued_spell(&after), "0"); - let casts = cast_events(&standalone); assert!( casts.iter().any(|row| is_power_failure(row, CLEAVE)), "one point short must fizzle with the same failure, got {casts:?}" @@ -132,7 +127,7 @@ fn a_queued_strike_fizzles_into_a_white_swing_when_the_rage_is_gone() { !casts.iter().any(|row| fired(row, CLEAVE)), "a fizzled strike must not report a cast, got {casts:?}" ); - assert_eq!(own_swing(&standalone)["spell_swing"], "false"); + assert_eq!(landed["spell_swing"], "false"); } fn stage_on_next_swing_spell(standalone: &Standalone, spell_id: u32, name: &str, cost: u32) { @@ -175,9 +170,11 @@ fn spawn_target(standalone: &Standalone) -> u64 { } fn engage(standalone: &Standalone, target: u64) { + let events = + standalone.subscribe(&["SELECT * FROM game_combat_event_live WHERE attacker_guid = 1"]); standalone.assert_call("debug_engage", &["1", &target.to_string()]); assert!( - poll_until(POLL_TIMEOUT, || !own_swings(standalone).is_empty()), + poll_until(POLL_TIMEOUT, || !own_swings(&events).is_empty()), "the engagement never swung" ); } @@ -195,16 +192,19 @@ fn cast(standalone: &Standalone, spell_id: u32, target: u64) { ); } -fn swing(standalone: &Standalone) { +fn swing(standalone: &Standalone) -> (Vec, SqlRow) { hold_the_swing(standalone); - standalone.assert_sql("DELETE FROM game_spell_cast_event"); - standalone.assert_sql("DELETE FROM game_combat_event"); + let events = standalone.subscribe(&[ + "SELECT * FROM game_combat_event_live WHERE attacker_guid = 1", + "SELECT * FROM game_spell_cast_event_live WHERE caster_guid = 1", + ]); standalone.assert_sql("UPDATE game_melee_attack SET last_swing_ms = 1 WHERE attacker_guid = 1"); assert!( - poll_until(POLL_TIMEOUT, || !own_swings(standalone).is_empty()), + poll_until(POLL_TIMEOUT, || !own_swings(&events).is_empty()), "the swing never fired" ); hold_the_swing(standalone); + (cast_events(&events), own_swing(&events)) } fn hold_the_swing(standalone: &Standalone) { @@ -234,11 +234,8 @@ fn queued_spell(row: &SqlRow) -> String { row["next_swing_spell"].clone() } -fn cast_events(standalone: &Standalone) -> Vec { - standalone.query_rows(&format!( - "SELECT spell_id, is_interrupted, is_completion, failure_reason FROM \ - game_spell_cast_event WHERE caster_guid = {PLAYER}" - )) +fn cast_events(events: &TableSubscription) -> Vec { + event_rows(events, "game_spell_cast_event_live") } fn is_power_failure(row: &SqlRow, spell_id: u32) -> bool { @@ -251,14 +248,12 @@ fn fired(row: &SqlRow, spell_id: u32) -> bool { row["spell_id"] == spell_id.to_string() && row["is_completion"] == "true" } -fn own_swings(standalone: &Standalone) -> Vec { - standalone.query_rows(&format!( - "SELECT damage, hit_info, spell_swing FROM game_combat_event WHERE attacker_guid = {PLAYER}" - )) +fn own_swings(events: &TableSubscription) -> Vec { + event_rows(events, "game_combat_event_live") } -fn own_swing(standalone: &Standalone) -> SqlRow { - own_swings(standalone) +fn own_swing(events: &TableSubscription) -> SqlRow { + own_swings(events) .pop() .expect("the swing must have been logged") } diff --git a/module/tests/random_property_stats.rs b/module/tests/random_property_stats.rs index 9b6474144..5767111ec 100644 --- a/module/tests/random_property_stats.rs +++ b/module/tests/random_property_stats.rs @@ -350,31 +350,29 @@ fn weapon_damage_uses_the_selected_main_off_and_ranged_item() { )); shard.assert_sql("UPDATE game_item_instance SET slot = 16, durability = 70 WHERE owner_guid = 1 AND slot = 17"); shard.assert_sql("UPDATE game_world_entity SET strength = 0, agility = 0, health = 100000, max_health = 100000 WHERE guid = 1"); - shard.assert_sql("DELETE FROM game_combat_event"); + let swings = shard.subscribe(&["SELECT * FROM game_combat_event_live WHERE attacker_guid = 1"]); shard.assert_call("debug_engage", &["1", &wolf]); assert!( poll_until(POLL_TIMEOUT, || { - shard - .query_rows("SELECT damage FROM game_combat_event WHERE attacker_guid = 1") + support::event_rows(&swings, "game_combat_event_live") .iter() .any(|row| row["damage"].parse::().unwrap() >= 14) }), "the off-hand stream never used its 14..16 property-adjusted range" ); + shard.assert_sql( + "UPDATE game_item_instance SET durability = 0 WHERE owner_guid = 1 AND slot = 16", + ); + drop(swings); + let swings = shard.subscribe(&["SELECT * FROM game_combat_event_live WHERE attacker_guid = 1"]); let before = shard .query_rows("SELECT last_offhand_swing_ms FROM game_melee_attack WHERE attacker_guid = 1") [0]["last_offhand_swing_ms"] .clone(); - shard.assert_sql( - "UPDATE game_item_instance SET durability = 0 WHERE owner_guid = 1 AND slot = 16", - ); - shard.assert_sql("DELETE FROM game_combat_event"); shard.assert_sql("UPDATE game_melee_attack SET last_swing_ms = 1 WHERE attacker_guid = 1"); assert!(poll_until(POLL_TIMEOUT, || { - !shard - .query_rows("SELECT id FROM game_combat_event WHERE attacker_guid = 1") - .is_empty() + !support::event_rows(&swings, "game_combat_event_live").is_empty() })); let after = shard .query_rows("SELECT last_offhand_swing_ms FROM game_melee_attack WHERE attacker_guid = 1") diff --git a/module/tests/scripted_spell_effect.rs b/module/tests/scripted_spell_effect.rs index f93f1f3bc..20c3289d6 100644 --- a/module/tests/scripted_spell_effect.rs +++ b/module/tests/scripted_spell_effect.rs @@ -5,7 +5,7 @@ use lyracore_shared::spell::taxonomy::{E_ENERGIZE, E_SCRIPTED, T_SELF, T_TARGET_ use std::collections::BTreeMap; use lyracore_shared::constants::unit_flags; -use support::Standalone; +use support::{event_rows, wait_for_event_rows, Standalone}; type SqlRow = BTreeMap; @@ -162,6 +162,9 @@ fn a_missing_or_disabled_script_refuses_the_cast_before_any_cost_is_spent() { ); let wolf = spawn_wolf(&standalone); + let casts = standalone.subscribe(&[&format!( + "SELECT * FROM game_spell_cast_event_live WHERE caster_guid = {PLAYER} AND spell_id = {E_GATE_SPELL}" + )]); let refused = standalone.call( "debug_force_cast_at", &[ @@ -179,7 +182,7 @@ fn a_missing_or_disabled_script_refuses_the_cast_before_any_cost_is_spent() { 1000, "a refused cast spends nothing" ); - assert!(cast_events_for(&standalone, E_GATE_SPELL).is_empty()); + assert!(event_rows(&casts, "game_spell_cast_event_live").is_empty()); insert_script( &standalone, @@ -205,7 +208,7 @@ fn a_missing_or_disabled_script_refuses_the_cast_before_any_cost_is_spent() { 1000, "a refused cast spends nothing" ); - assert!(cast_events_for(&standalone, E_GATE_SPELL).is_empty()); + assert!(event_rows(&casts, "game_spell_cast_event_live").is_empty()); standalone.assert_sql(&format!( "UPDATE game_script SET enabled = true WHERE script_id = {GATE_SCRIPT}" @@ -219,7 +222,10 @@ fn a_missing_or_disabled_script_refuses_the_cast_before_any_cost_is_spent() { ], ); assert_eq!(power(&standalone, PLAYER), 1000 - GATE_SPELL_COST); - assert_eq!(cast_events_for(&standalone, E_GATE_SPELL).len(), 1); + assert_eq!( + wait_for_event_rows(&casts, "game_spell_cast_event_live").len(), + 1 + ); } #[test] @@ -383,9 +389,3 @@ fn xp(standalone: &Standalone, guid: u64) -> u32 { .parse() .expect("xp is a number") } - -fn cast_events_for(standalone: &Standalone, spell_id: u32) -> Vec { - standalone.query_rows(&format!( - "SELECT spell_id FROM game_spell_cast_event WHERE caster_guid = {PLAYER} AND spell_id = {spell_id}" - )) -} diff --git a/module/tests/support/mod.rs b/module/tests/support/mod.rs index 38fff99ef..59934f616 100644 --- a/module/tests/support/mod.rs +++ b/module/tests/support/mod.rs @@ -9,6 +9,46 @@ pub const LEG_YD: f32 = 20.0; pub type Row = BTreeMap; +pub fn event_rows(subscription: &TableSubscription, table: &str) -> Vec { + subscription + .inserts(table) + .into_iter() + .map(|row| { + row.as_object() + .expect("event row is an object") + .iter() + .map(|(column, value)| { + let value = if let Some(micros) = + value["__timestamp_micros_since_unix_epoch__"].as_i64() + { + spacetimedb::Timestamp::from_micros_since_unix_epoch(micros) + .to_rfc3339() + .expect("event timestamp is representable") + } else { + value + .as_str() + .map(str::to_owned) + .unwrap_or_else(|| value.to_string()) + }; + (column.clone(), value) + }) + .collect() + }) + .collect() +} + +pub fn wait_for_event_rows(subscription: &TableSubscription, table: &str) -> Vec { + let mut rows = Vec::new(); + assert!( + poll_until(POLL_TIMEOUT, || { + rows = event_rows(subscription, table); + !rows.is_empty() + }), + "no inserts arrived for {table}" + ); + rows +} + pub fn number(row: &Row, column: &str) -> T { row[column] .parse() diff --git a/module/tests/swing_error.rs b/module/tests/swing_error.rs index 82ad8d757..3b4e36997 100644 --- a/module/tests/swing_error.rs +++ b/module/tests/swing_error.rs @@ -1,6 +1,6 @@ mod support; -use support::{number, poll_until, Row, Standalone, POLL_TIMEOUT}; +use support::{event_rows, number, poll_until, Row, Standalone, TableSubscription, POLL_TIMEOUT}; const PLAYER: u64 = 1; const TEST_WOLF_ENTRY: u32 = 51000; @@ -25,29 +25,37 @@ fn a_due_swing_records_out_of_range_and_bad_facing_and_a_landed_swing_clears_it( "UPDATE game_world_entity SET orientation = 0.0 WHERE guid = {PLAYER}" )); + let events = standalone.subscribe(&[ + "SELECT * FROM game_combat_event_live WHERE attacker_guid = 1", + "SELECT * FROM game_melee_attack WHERE attacker_guid = 1", + ]); standalone.assert_call("debug_engage", &[&PLAYER.to_string(), &wolf.to_string()]); - wait_for_swing_error(&standalone, NOT_IN_RANGE); + wait_for_swing_error(&events, NOT_IN_RANGE); assert!( - own_swings(&standalone).is_empty(), + own_swings(&events).is_empty(), "an out-of-range swing must not land" ); place(&standalone, wolf, x + 1.0, y); make_swing_due(&standalone); assert!( - poll_until(POLL_TIMEOUT, || !own_swings(&standalone).is_empty()), + poll_until(POLL_TIMEOUT, || !own_swings(&events).is_empty()), "the swing in reach never landed" ); - wait_for_swing_error(&standalone, NONE); + wait_for_swing_error(&events, NONE); standalone.assert_sql(&format!( "UPDATE game_world_entity SET orientation = 3.14159 WHERE guid = {PLAYER}" )); - standalone.assert_sql("DELETE FROM game_combat_event"); + drop(events); + let events = standalone.subscribe(&[ + "SELECT * FROM game_combat_event_live WHERE attacker_guid = 1", + "SELECT * FROM game_melee_attack WHERE attacker_guid = 1", + ]); make_swing_due(&standalone); - wait_for_swing_error(&standalone, BAD_FACING); + wait_for_swing_error(&events, BAD_FACING); assert!( - own_swings(&standalone).is_empty(), + own_swings(&events).is_empty(), "a swing at a target behind the player must not land" ); } @@ -93,25 +101,20 @@ fn make_swing_due(standalone: &Standalone) { )); } -fn swing_error(standalone: &Standalone) -> Option { - standalone - .query_rows(&format!( - "SELECT swing_error FROM game_melee_attack WHERE attacker_guid = {PLAYER}" - )) +fn swing_error(events: &TableSubscription) -> Option { + event_rows(events, "game_melee_attack") .pop() .map(|row| number(&row, "swing_error")) } -fn wait_for_swing_error(standalone: &Standalone, expected: u8) { +fn wait_for_swing_error(events: &TableSubscription, expected: u8) { assert!( - poll_until(POLL_TIMEOUT, || swing_error(standalone) == Some(expected)), + poll_until(POLL_TIMEOUT, || swing_error(events) == Some(expected)), "the Swing Error must become {expected}, got {:?}", - swing_error(standalone) + swing_error(events) ); } -fn own_swings(standalone: &Standalone) -> Vec { - standalone.query_rows(&format!( - "SELECT damage FROM game_combat_event WHERE attacker_guid = {PLAYER}" - )) +fn own_swings(events: &TableSubscription) -> Vec { + event_rows(events, "game_combat_event_live") } From 2c9e5e07d123e2d0e21a66bf7e81c24243412704 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Fri, 16 Oct 2026 08:18:43 +0200 Subject: [PATCH 2/2] test(module): fence notification assertions on the subscription stream --- module/tests/cast_global_cooldown.rs | 44 +++++++++++++++------------ module/tests/hearthstone.rs | 14 ++++++--- module/tests/scripted_spell_effect.rs | 12 ++++---- module/tests/support/mod.rs | 36 ++++++++++++++++++++++ 4 files changed, 77 insertions(+), 29 deletions(-) diff --git a/module/tests/cast_global_cooldown.rs b/module/tests/cast_global_cooldown.rs index 4216a9454..a1e0691aa 100644 --- a/module/tests/cast_global_cooldown.rs +++ b/module/tests/cast_global_cooldown.rs @@ -2,7 +2,8 @@ mod support; use spacetimedb::Timestamp; use support::{ - event_rows, poll_until, wait_for_event_rows, Row, Standalone, TableSubscription, POLL_TIMEOUT, + event_rows, fence_subscription, poll_until, subscribe_with_fence, wait_for_event_rows, Row, + Standalone, TableSubscription, POLL_TIMEOUT, }; const WOLF: u64 = (0xF130_u64 << 48) | (51000_u64 << 24) | 1; @@ -12,11 +13,12 @@ const ACTOR: &str = r#"{"guid":1,"ownership":null}"#; #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn fireball_can_be_cast_again_when_its_cast_bar_finishes() { let shard = fixture("fireball-global-cooldown", 2000, 1500, 0); - let casts = shard.subscribe(&[ - "SELECT * FROM game_spell_cast_event_live WHERE caster_guid = 1 AND spell_id = 133", - ]); + let casts = subscribe_with_fence( + &shard, + &["SELECT * FROM game_spell_cast_event_live WHERE caster_guid = 1 AND spell_id = 133"], + ); cast_fireball(&shard); - let completed_at = timestamp(&completion(&casts), "created_at"); + let completed_at = timestamp(&completion(&shard, &casts), "created_at"); let ready_at = timestamp(&global_cooldown(&shard), "ready_at"); assert!( ready_at <= completed_at, @@ -35,9 +37,10 @@ fn fireball_can_be_cast_again_when_its_cast_bar_finishes() { #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn a_short_cast_finishes_during_its_original_global_cooldown() { let shard = fixture("short-cast-global-cooldown", 500, 5000, 7000); - let casts = shard.subscribe(&[ - "SELECT * FROM game_spell_cast_event_live WHERE caster_guid = 1 AND spell_id = 133", - ]); + let casts = subscribe_with_fence( + &shard, + &["SELECT * FROM game_spell_cast_event_live WHERE caster_guid = 1 AND spell_id = 133"], + ); cast_fireball(&shard); let started_at = timestamp( wait_for_event_rows(&casts, "game_spell_cast_event_live") @@ -51,7 +54,7 @@ fn a_short_cast_finishes_during_its_original_global_cooldown() { timestamp(&original_cooldown, "ready_at"), started_at + 5_000_000 ); - let completed_at = timestamp(&completion(&casts), "created_at"); + let completed_at = timestamp(&completion(&shard, &casts), "created_at"); assert!(completed_at < timestamp(&original_cooldown, "ready_at")); assert_eq!(global_cooldown(&shard), original_cooldown); let spell_cooldown = shard.query_rows("SELECT * FROM game_spell_cd WHERE caster_guid = 1"); @@ -66,9 +69,10 @@ fn a_short_cast_finishes_during_its_original_global_cooldown() { #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn an_instant_cast_still_starts_a_global_cooldown() { let shard = fixture("instant-cast-global-cooldown", 0, 5000, 0); - let casts = shard.subscribe(&[ - "SELECT * FROM game_spell_cast_event_live WHERE caster_guid = 1 AND spell_id = 133", - ]); + let casts = subscribe_with_fence( + &shard, + &["SELECT * FROM game_spell_cast_event_live WHERE caster_guid = 1 AND spell_id = 133"], + ); cast_fireball(&shard); let launched_at = timestamp( &wait_for_event_rows(&casts, "game_spell_cast_event_live")[0], @@ -135,18 +139,20 @@ fn cast_fireball(shard: &Standalone) { shard.assert_call("gw_cast_spell", &[ACTOR, "133", &WOLF.to_string()]); } -fn completion(casts: &TableSubscription) -> Row { - let mut rows = Vec::new(); +fn completion(shard: &Standalone, casts: &TableSubscription) -> Row { assert!( poll_until(POLL_TIMEOUT, || { - rows = event_rows(casts, "game_spell_cast_event_live") - .into_iter() - .filter(|row| row["is_completion"] == "true") - .collect(); - !rows.is_empty() + event_rows(casts, "game_spell_cast_event_live") + .iter() + .any(|row| row["is_completion"] == "true") }), "the timed cast did not launch" ); + fence_subscription(shard, casts); + let mut rows: Vec<_> = event_rows(casts, "game_spell_cast_event_live") + .into_iter() + .filter(|row| row["is_completion"] == "true") + .collect(); assert_eq!(rows.len(), 1); rows.remove(0) } diff --git a/module/tests/hearthstone.rs b/module/tests/hearthstone.rs index 6d1014e4e..b31f728b8 100644 --- a/module/tests/hearthstone.rs +++ b/module/tests/hearthstone.rs @@ -2,7 +2,10 @@ mod support; use spacetimedb::Timestamp; use std::time::Duration; -use support::{actor, event_rows, poll_until, wait_for_event_rows, Row, Standalone, POLL_TIMEOUT}; +use support::{ + actor, event_rows, fence_subscription, poll_until, subscribe_with_fence, Row, Standalone, + POLL_TIMEOUT, +}; fn fixture(name: &str) -> Standalone { let mut shard = Standalone::start(name); @@ -39,14 +42,17 @@ fn timestamp(row: &Row) -> i64 { #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn hearthstone_recalls_to_the_bound_home_only_after_ten_seconds() { let shard = fixture("hearthstone-completion"); - let casts = - shard.subscribe(&["SELECT * FROM game_spell_cast_event_live WHERE spell_id = 8690"]); + let casts = subscribe_with_fence( + &shard, + &["SELECT * FROM game_spell_cast_event_live WHERE spell_id = 8690"], + ); let before = position(&shard); let inventory = items(&shard); use_hearthstone(&shard); assert_eq!(position(&shard), before); assert_eq!(items(&shard), inventory); - let started = wait_for_event_rows(&casts, "game_spell_cast_event_live"); + fence_subscription(&shard, &casts); + let started = event_rows(&casts, "game_spell_cast_event_live"); assert_eq!(started.len(), 1); assert_eq!(started[0]["cast_time_ms"], "10000"); assert_eq!( diff --git a/module/tests/scripted_spell_effect.rs b/module/tests/scripted_spell_effect.rs index 20c3289d6..187eb57a0 100644 --- a/module/tests/scripted_spell_effect.rs +++ b/module/tests/scripted_spell_effect.rs @@ -5,7 +5,7 @@ use lyracore_shared::spell::taxonomy::{E_ENERGIZE, E_SCRIPTED, T_SELF, T_TARGET_ use std::collections::BTreeMap; use lyracore_shared::constants::unit_flags; -use support::{event_rows, wait_for_event_rows, Standalone}; +use support::{event_rows, fence_subscription, subscribe_with_fence, Standalone}; type SqlRow = BTreeMap; @@ -162,7 +162,7 @@ fn a_missing_or_disabled_script_refuses_the_cast_before_any_cost_is_spent() { ); let wolf = spawn_wolf(&standalone); - let casts = standalone.subscribe(&[&format!( + let casts = subscribe_with_fence(&standalone, &[&format!( "SELECT * FROM game_spell_cast_event_live WHERE caster_guid = {PLAYER} AND spell_id = {E_GATE_SPELL}" )]); let refused = standalone.call( @@ -182,6 +182,7 @@ fn a_missing_or_disabled_script_refuses_the_cast_before_any_cost_is_spent() { 1000, "a refused cast spends nothing" ); + fence_subscription(&standalone, &casts); assert!(event_rows(&casts, "game_spell_cast_event_live").is_empty()); insert_script( @@ -208,6 +209,7 @@ fn a_missing_or_disabled_script_refuses_the_cast_before_any_cost_is_spent() { 1000, "a refused cast spends nothing" ); + fence_subscription(&standalone, &casts); assert!(event_rows(&casts, "game_spell_cast_event_live").is_empty()); standalone.assert_sql(&format!( @@ -222,10 +224,8 @@ fn a_missing_or_disabled_script_refuses_the_cast_before_any_cost_is_spent() { ], ); assert_eq!(power(&standalone, PLAYER), 1000 - GATE_SPELL_COST); - assert_eq!( - wait_for_event_rows(&casts, "game_spell_cast_event_live").len(), - 1 - ); + fence_subscription(&standalone, &casts); + assert_eq!(event_rows(&casts, "game_spell_cast_event_live").len(), 1); } #[test] diff --git a/module/tests/support/mod.rs b/module/tests/support/mod.rs index 59934f616..219dc600c 100644 --- a/module/tests/support/mod.rs +++ b/module/tests/support/mod.rs @@ -1,6 +1,7 @@ #![allow(dead_code)] use std::collections::BTreeMap; +use std::sync::atomic::{AtomicU64, Ordering}; use lyracore_shared::spatial::{grid_cell, GRID_CELL_SIZE, MAP_COORD_MAX}; pub use lyracore_test_support::*; @@ -9,6 +10,41 @@ pub const LEG_YD: f32 = 20.0; pub type Row = BTreeMap; +const SUBSCRIPTION_MARKER: u64 = 509_9999; + +pub fn subscribe_with_fence(shard: &Standalone, queries: &[&str]) -> TableSubscription { + shard.assert_sql(&format!( + "DELETE FROM game_package_config WHERE id = {SUBSCRIPTION_MARKER}" + )); + shard.assert_sql(&format!( + "INSERT INTO game_package_config (id, package_name, key, value) \ + VALUES ({SUBSCRIPTION_MARKER}, 'fixture.subscription', 'transaction', 'initial')" + )); + let marker_query = + format!("SELECT * FROM game_package_config WHERE id = {SUBSCRIPTION_MARKER}"); + let mut queries = queries.to_vec(); + queries.push(&marker_query); + shard.subscribe(&queries) +} + +// A marker on the same stream proves all earlier committed updates reached the recorder. +pub fn fence_subscription(shard: &Standalone, subscription: &TableSubscription) { + static NEXT_MARKER: AtomicU64 = AtomicU64::new(1); + let marker = NEXT_MARKER.fetch_add(1, Ordering::Relaxed).to_string(); + shard.assert_sql(&format!( + "UPDATE game_package_config SET value = '{marker}' WHERE id = {SUBSCRIPTION_MARKER}" + )); + assert!( + poll_until(POLL_TIMEOUT, || { + subscription + .inserts("game_package_config") + .iter() + .any(|row| row["id"] == SUBSCRIPTION_MARKER && row["value"] == marker) + }), + "the subscription did not receive transaction marker {marker}" + ); +} + pub fn event_rows(subscription: &TableSubscription, table: &str) -> Vec { subscription .inserts(table)