From 40adfb42448f79dc6bbff112cf871a06b67fc01c Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 06:23:41 +0000 Subject: [PATCH 1/3] fix(vmap): make closed instance doors block rays --- CONTEXT.md | 7 + crates/lyracore-shared/src/vmap.rs | 2 +- docs/vmap-rollout.md | 32 +- .../debug_assert_go_collision_reducer.rs | 62 +++ .../debug_vmap_ray_instance_reducer.rs | 117 ++++++ .../stdb/bindings/game_go_collider_table.rs | 228 +++++++++++ gateway/src/stdb/bindings/go_collider_type.rs | 70 ++++ gateway/src/stdb/bindings/mod.rs | 73 +++- .../bindings/rebuild_go_colliders_reducer.rs | 62 +++ module/src/combat/engage.rs | 1 + module/src/combat/swing.rs | 1 + module/src/creatures/cycle/ctx.rs | 19 +- module/src/creatures/cycle/harness.rs | 4 +- module/src/creatures/eventai/engine.rs | 1 + module/src/creatures/spawn.rs | 9 +- module/src/creatures/tick/mod.rs | 2 +- module/src/debug/instance.rs | 51 ++- module/src/debug/mod.rs | 4 +- module/src/gameobject.rs | 50 +-- module/src/go_collider.rs | 305 ++++++++++++++ module/src/go_collider_fixture.rs | 375 ++++++++++++++++++ module/src/go_model.rs | 64 ++- module/src/instance.rs | 4 +- module/src/lib.rs | 3 + module/src/nav.rs | 46 +-- module/src/package_import.rs | 4 + module/src/spell/cast/targeting.rs | 81 ++-- module/src/terrain.rs | 11 +- module/src/tripwires.rs | 1 + module/src/vmap.rs | 127 +++--- module/tests/go_collision.rs | 21 + 31 files changed, 1634 insertions(+), 203 deletions(-) create mode 100644 gateway/src/stdb/bindings/debug_assert_go_collision_reducer.rs create mode 100644 gateway/src/stdb/bindings/debug_vmap_ray_instance_reducer.rs create mode 100644 gateway/src/stdb/bindings/game_go_collider_table.rs create mode 100644 gateway/src/stdb/bindings/go_collider_type.rs create mode 100644 gateway/src/stdb/bindings/rebuild_go_colliders_reducer.rs create mode 100644 module/src/go_collider.rs create mode 100644 module/src/go_collider_fixture.rs create mode 100644 module/tests/go_collision.rs diff --git a/CONTEXT.md b/CONTEXT.md index 482142ada..3d021a497 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -866,3 +866,10 @@ reference even when the Stat Kind is unknown. Enchantment IDs 7745 and 7748 are authored compatibility entries, with +3 Strength and +3 Stamina. They preserve existing stored item meanings. They are not rows from the build 5875 client catalogue. The importer refuses a client catalogue that collides with either ID. + +### GameObject collision + +**GameObject Collider**: +Derived registration of a DOOR or BUTTON mesh for one GameObject in one map and instance. +A closed, live GameObject blocks sight and collision rays. Its registration remains while open. +Static geometry retains its separate active-generation rule. diff --git a/crates/lyracore-shared/src/vmap.rs b/crates/lyracore-shared/src/vmap.rs index 3f530651d..edac9d0e7 100644 --- a/crates/lyracore-shared/src/vmap.rs +++ b/crates/lyracore-shared/src/vmap.rs @@ -360,7 +360,7 @@ fn dot3(a: [f32; 3], b: [f32; 3]) -> f32 { /// Möller–Trumbore, no backface culling (both winding orders block), segment-clamped (`t` in /// `0..=1`, not an infinite ray). Returns the hit parameter along `a -> b`, or `None`. -fn segment_tri_hit(a: [f32; 3], b: [f32; 3], tri: [[f32; 3]; 3]) -> Option { +pub fn segment_tri_hit(a: [f32; 3], b: [f32; 3], tri: [[f32; 3]; 3]) -> Option { let dir = sub3(b, a); let e1 = sub3(tri[1], tri[0]); let e2 = sub3(tri[2], tri[0]); diff --git a/docs/vmap-rollout.md b/docs/vmap-rollout.md index df4d77460..4d53e1c4d 100644 --- a/docs/vmap-rollout.md +++ b/docs/vmap-rollout.md @@ -52,8 +52,8 @@ checking generations. A successful import is data-plane evidence, not permission ## Geometry-probe readiness Issue #184 requires ray and floor evidence before exact-vmap consumption is enabled. The current -`debug_vmap_ray` and `debug_floor_probe` reducers read the active generation directly and do not -require `vmap_enabled`; use them while the gameplay gate remains off. Do not use a temporary +`debug_vmap_ray` and `debug_floor_probe` reducers read active static geometry and registered doors. +They do not require `vmap_enabled`; use them while the gameplay Gate remains off. Do not use a temporary `debug_set_vmap_enabled true` change as a substitute: it already enables the behavior this checklist is meant to accept. @@ -129,3 +129,31 @@ Nearby movement and line-of-sight observation: Verdict: PASS / FAIL / INCONCLUSIVE Links to raw importer output and server/client evidence: ``` + +## Instance doors + +DOOR and BUTTON models also participate in sight and collision rays when `vmap_enabled` is on. +They use `game_go_collider` rows indexed by map and instance. Map 36 needs no static generation +for these rays. Static geometry retains the active-generation requirement; the navigation grid +still supplies the fallback on maps without one. Area and indoor queries stay static. + +GameObject imports, model imports, debug spawns and instance creation register eligible doors. +Gameobjects Package Delta apply and replay reconcile after all spawn and template changes finish. +Model imports rebuild the registry so either import order works. `rebuild_go_colliders` is an +Operator reconciliation for existing rows after a publish. Open doors keep their registration; +rays check their current state. Despawn and instance reap remove registration. + +Collision scale and triangles come from `game_go_model`. A Package that changes a template's +size or display requires an explicitly matching model artifact. This path does not regenerate +meshes from Package Claims; a routine model import does not establish that those Claims match. + +Before imported Deadmines acceptance, import approved DOOR/BUTTON models on the named development +Shard and run `rebuild_go_colliders`. Use `debug_vmap_ray_instance` with the instance id as its +final argument. The original `debug_vmap_ray` command still probes instance 0. Record the Factory +Door hit while closed, the clear ray after `debug_encounter_open_door`, and a closed sibling instance. +These probes also calibrate the +imported model axes and spawn quaternion. Synthetic tests do not establish imported-client parity. + +Retain `debug_bench_collision_gate` and `debug_bench_los` evidence on the imported canonical box +before enabling gameplay consumption. Those performance and real-client checks remain Operator +acceptance; the private durable fixture proves geometry and lifecycle behavior. diff --git a/gateway/src/stdb/bindings/debug_assert_go_collision_reducer.rs b/gateway/src/stdb/bindings/debug_assert_go_collision_reducer.rs new file mode 100644 index 000000000..9d6d1ae49 --- /dev/null +++ b/gateway/src/stdb/bindings/debug_assert_go_collision_reducer.rs @@ -0,0 +1,62 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct DebugAssertGoCollisionArgs {} + +impl From for super::Reducer { + fn from(args: DebugAssertGoCollisionArgs) -> Self { + Self::DebugAssertGoCollision + } +} + +impl __sdk::InModule for DebugAssertGoCollisionArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `debug_assert_go_collision`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait debug_assert_go_collision { + /// Request that the remote module invoke the reducer `debug_assert_go_collision` 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_assert_go_collision:debug_assert_go_collision_then`] to run a callback after the reducer completes. + fn debug_assert_go_collision(&self) -> __sdk::Result<()> { + self.debug_assert_go_collision_then(|_, _| {}) + } + + /// Request that the remote module invoke the reducer `debug_assert_go_collision` 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_assert_go_collision_then( + &self, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl debug_assert_go_collision for super::RemoteReducers { + fn debug_assert_go_collision_then( + &self, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp + .invoke_reducer_with_callback(DebugAssertGoCollisionArgs {}, callback) + } +} diff --git a/gateway/src/stdb/bindings/debug_vmap_ray_instance_reducer.rs b/gateway/src/stdb/bindings/debug_vmap_ray_instance_reducer.rs new file mode 100644 index 000000000..eecfc60fd --- /dev/null +++ b/gateway/src/stdb/bindings/debug_vmap_ray_instance_reducer.rs @@ -0,0 +1,117 @@ +// 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 DebugVmapRayInstanceArgs { + pub map: u32, + pub x_0: f32, + pub y_0: f32, + pub z_0: f32, + pub x_1: f32, + pub y_1: f32, + pub z_1: f32, + pub instance_id: u64, +} + +impl From for super::Reducer { + fn from(args: DebugVmapRayInstanceArgs) -> Self { + Self::DebugVmapRayInstance { + map: args.map, + x_0: args.x_0, + y_0: args.y_0, + z_0: args.z_0, + x_1: args.x_1, + y_1: args.y_1, + z_1: args.z_1, + instance_id: args.instance_id, + } + } +} + +impl __sdk::InModule for DebugVmapRayInstanceArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `debug_vmap_ray_instance`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait debug_vmap_ray_instance { + /// Request that the remote module invoke the reducer `debug_vmap_ray_instance` 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_vmap_ray_instance:debug_vmap_ray_instance_then`] to run a callback after the reducer completes. + fn debug_vmap_ray_instance( + &self, + map: u32, + x_0: f32, + y_0: f32, + z_0: f32, + x_1: f32, + y_1: f32, + z_1: f32, + instance_id: u64, + ) -> __sdk::Result<()> { + self.debug_vmap_ray_instance_then(map, x_0, y_0, z_0, x_1, y_1, z_1, instance_id, |_, _| {}) + } + + /// Request that the remote module invoke the reducer `debug_vmap_ray_instance` 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_vmap_ray_instance_then( + &self, + map: u32, + x_0: f32, + y_0: f32, + z_0: f32, + x_1: f32, + y_1: f32, + z_1: f32, + instance_id: u64, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl debug_vmap_ray_instance for super::RemoteReducers { + fn debug_vmap_ray_instance_then( + &self, + map: u32, + x_0: f32, + y_0: f32, + z_0: f32, + x_1: f32, + y_1: f32, + z_1: f32, + instance_id: u64, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp.invoke_reducer_with_callback( + DebugVmapRayInstanceArgs { + map, + x_0, + y_0, + z_0, + x_1, + y_1, + z_1, + instance_id, + }, + callback, + ) + } +} diff --git a/gateway/src/stdb/bindings/game_go_collider_table.rs b/gateway/src/stdb/bindings/game_go_collider_table.rs new file mode 100644 index 000000000..4629ca1f5 --- /dev/null +++ b/gateway/src/stdb/bindings/game_go_collider_table.rs @@ -0,0 +1,228 @@ +// 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 super::go_collider_type::GoCollider; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `game_go_collider`. +/// +/// Obtain a handle from the [`GameGoColliderTableAccess::game_go_collider`] method on [`super::RemoteTables`], +/// like `ctx.db.game_go_collider()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.game_go_collider().on_insert(...)`. +pub struct GameGoColliderTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +/// Lifetime-aware accessor marker for the table `game_go_collider`. +pub struct GameGoColliderTableAccessor; + +impl __sdk::TableAccessor for GameGoColliderTableAccessor { + type Row = GoCollider; + type Handle<'db> = GameGoColliderTableHandle<'db>; + + fn get<'db>(db: &'db super::RemoteTables) -> Self::Handle<'db> { + db.game_go_collider() + } +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `game_go_collider`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait GameGoColliderTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`GameGoColliderTableHandle`], which mediates access to the table `game_go_collider`. + fn game_go_collider(&self) -> GameGoColliderTableHandle<'_>; +} + +impl GameGoColliderTableAccess for super::RemoteTables { + fn game_go_collider(&self) -> GameGoColliderTableHandle<'_> { + GameGoColliderTableHandle { + imp: self.imp.get_table::("game_go_collider"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct GameGoColliderInsertCallbackId(__sdk::CallbackId); +pub struct GameGoColliderDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableLike for GameGoColliderTableHandle<'ctx> { + type Row = GoCollider; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } +} + +impl<'ctx> __sdk::Table for GameGoColliderTableHandle<'ctx> { + type Row = GoCollider; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = GameGoColliderInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameGoColliderInsertCallbackId { + GameGoColliderInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: GameGoColliderInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = GameGoColliderDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameGoColliderDeleteCallbackId { + GameGoColliderDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: GameGoColliderDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +impl<'ctx> __sdk::WithInsert for GameGoColliderTableHandle<'ctx> { + type InsertCallbackId = GameGoColliderInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameGoColliderInsertCallbackId { + GameGoColliderInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: GameGoColliderInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } +} + +impl<'ctx> __sdk::WithDelete for GameGoColliderTableHandle<'ctx> { + type DeleteCallbackId = GameGoColliderDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> GameGoColliderDeleteCallbackId { + GameGoColliderDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: GameGoColliderDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct GameGoColliderUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for GameGoColliderTableHandle<'ctx> { + type UpdateCallbackId = GameGoColliderUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> GameGoColliderUpdateCallbackId { + GameGoColliderUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: GameGoColliderUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +impl<'ctx> __sdk::WithUpdate for GameGoColliderTableHandle<'ctx> { + type UpdateCallbackId = GameGoColliderUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> GameGoColliderUpdateCallbackId { + GameGoColliderUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: GameGoColliderUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `go_guid` unique index on the table `game_go_collider`, +/// which allows point queries on the field of the same name +/// via the [`GameGoColliderGoGuidUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.game_go_collider().go_guid().find(...)`. +pub struct GameGoColliderGoGuidUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> GameGoColliderTableHandle<'ctx> { + /// Get a handle on the `go_guid` unique index on the table `game_go_collider`. + pub fn go_guid(&self) -> GameGoColliderGoGuidUnique<'ctx> { + GameGoColliderGoGuidUnique { + imp: self.imp.get_unique_constraint::("go_guid"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> GameGoColliderGoGuidUnique<'ctx> { + /// Find the subscribed row whose `go_guid` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &u64) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("game_go_collider"); + _table.add_unique_constraint::("go_guid", |row| &row.go_guid); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `GoCollider`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait game_go_colliderQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `GoCollider`. + fn game_go_collider(&self) -> __sdk::__query_builder::Table; +} + +impl game_go_colliderQueryTableAccess for __sdk::QueryTableAccessor { + fn game_go_collider(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("game_go_collider") + } +} diff --git a/gateway/src/stdb/bindings/go_collider_type.rs b/gateway/src/stdb/bindings/go_collider_type.rs new file mode 100644 index 000000000..3eeb1b12b --- /dev/null +++ b/gateway/src/stdb/bindings/go_collider_type.rs @@ -0,0 +1,70 @@ +// 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 struct GoCollider { + pub go_guid: u64, + pub map_id: u32, + pub instance_id: u64, + pub entry: u32, + pub x: f32, + pub y: f32, + pub z: f32, + pub radius_world: f32, +} + +impl __sdk::InModule for GoCollider { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `GoCollider`. +/// +/// Provides typed access to columns for query building. +pub struct GoColliderCols { + pub go_guid: __sdk::__query_builder::Col, + pub map_id: __sdk::__query_builder::Col, + pub instance_id: __sdk::__query_builder::Col, + pub entry: __sdk::__query_builder::Col, + pub x: __sdk::__query_builder::Col, + pub y: __sdk::__query_builder::Col, + pub z: __sdk::__query_builder::Col, + pub radius_world: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for GoCollider { + type Cols = GoColliderCols; + fn cols(table_name: &'static str) -> Self::Cols { + GoColliderCols { + go_guid: __sdk::__query_builder::Col::new(table_name, "go_guid"), + map_id: __sdk::__query_builder::Col::new(table_name, "map_id"), + instance_id: __sdk::__query_builder::Col::new(table_name, "instance_id"), + entry: __sdk::__query_builder::Col::new(table_name, "entry"), + x: __sdk::__query_builder::Col::new(table_name, "x"), + y: __sdk::__query_builder::Col::new(table_name, "y"), + z: __sdk::__query_builder::Col::new(table_name, "z"), + radius_world: __sdk::__query_builder::Col::new(table_name, "radius_world"), + } + } +} + +/// Indexed column accessor struct for the table `GoCollider`. +/// +/// Provides typed access to indexed columns for query building. +pub struct GoColliderIxCols { + pub go_guid: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for GoCollider { + type IxCols = GoColliderIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + GoColliderIxCols { + go_guid: __sdk::__query_builder::IxCol::new(table_name, "go_guid"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for GoCollider {} diff --git a/gateway/src/stdb/bindings/mod.rs b/gateway/src/stdb/bindings/mod.rs index c3d25fcb5..d3a076095 100644 --- a/gateway/src/stdb/bindings/mod.rs +++ b/gateway/src/stdb/bindings/mod.rs @@ -118,6 +118,7 @@ pub mod debug_arm_instance_tick_reducer; pub mod debug_assert_blink_clamp_reducer; pub mod debug_assert_chase_stops_at_column_reducer; pub mod debug_assert_floor_snap_reducer; +pub mod debug_assert_go_collision_reducer; pub mod debug_assert_unreachable_goal_stops_at_wall_reducer; pub mod debug_audit_class_kits_reducer; pub mod debug_audit_quest_chains_reducer; @@ -266,6 +267,7 @@ 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; pub mod debug_vmap_area_info_reducer; +pub mod debug_vmap_ray_instance_reducer; pub mod debug_vmap_ray_reducer; pub mod delete_character_reducer; pub mod discard_vmap_generation_reducer; @@ -426,6 +428,7 @@ pub mod game_gameobject_unlocked_table; pub mod game_gateway_lease_reaper_schedule_table; pub mod game_gateway_lease_table; pub mod game_gateway_session_table; +pub mod game_go_collider_table; pub mod game_go_model_table; pub mod game_gossip_menu_profile_option_table; pub mod game_gossip_menu_profile_table; @@ -582,6 +585,7 @@ pub mod gateway_lease_reaper_schedule_type; pub mod gateway_lease_type; pub mod gateway_session_type; pub mod gm_world_config_type; +pub mod go_collider_type; pub mod go_model_type; pub mod gossip_menu_profile_option_type; pub mod gossip_menu_profile_type; @@ -833,6 +837,7 @@ pub mod reap_mail_escrows_reducer; pub mod reap_movement_events_reducer; pub mod reap_sessions_reducer; pub mod reap_transfers_reducer; +pub mod rebuild_go_colliders_reducer; pub mod receive_ai_event_condition_type; pub mod receive_emote_condition_type; pub mod record_region_load_reducer; @@ -1108,6 +1113,7 @@ pub use debug_arm_instance_tick_reducer::debug_arm_instance_tick; pub use debug_assert_blink_clamp_reducer::debug_assert_blink_clamp; pub use debug_assert_chase_stops_at_column_reducer::debug_assert_chase_stops_at_column; pub use debug_assert_floor_snap_reducer::debug_assert_floor_snap; +pub use debug_assert_go_collision_reducer::debug_assert_go_collision; pub use debug_assert_unreachable_goal_stops_at_wall_reducer::debug_assert_unreachable_goal_stops_at_wall; pub use debug_audit_class_kits_reducer::debug_audit_class_kits; pub use debug_audit_quest_chains_reducer::debug_audit_quest_chains; @@ -1256,6 +1262,7 @@ pub use debug_verify_lethal_damage_floor_fixture_reducer::debug_verify_lethal_da 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; pub use debug_vmap_area_info_reducer::debug_vmap_area_info; +pub use debug_vmap_ray_instance_reducer::debug_vmap_ray_instance; pub use debug_vmap_ray_reducer::debug_vmap_ray; pub use delete_character_reducer::delete_character; pub use discard_vmap_generation_reducer::discard_vmap_generation; @@ -1416,6 +1423,7 @@ pub use game_gameobject_unlocked_table::*; pub use game_gateway_lease_reaper_schedule_table::*; pub use game_gateway_lease_table::*; pub use game_gateway_session_table::*; +pub use game_go_collider_table::*; pub use game_go_model_table::*; pub use game_gossip_menu_profile_option_table::*; pub use game_gossip_menu_profile_table::*; @@ -1572,6 +1580,7 @@ pub use gateway_lease_reaper_schedule_type::GatewayLeaseReaperSchedule; pub use gateway_lease_type::GatewayLease; pub use gateway_session_type::GatewaySession; pub use gm_world_config_type::GmWorldConfig; +pub use go_collider_type::GoCollider; pub use go_model_type::GoModel; pub use gossip_menu_profile_option_type::GossipMenuProfileOption; pub use gossip_menu_profile_type::GossipMenuProfile; @@ -1823,6 +1832,7 @@ pub use reap_mail_escrows_reducer::reap_mail_escrows; pub use reap_movement_events_reducer::reap_movement_events; pub use reap_sessions_reducer::reap_sessions; pub use reap_transfers_reducer::reap_transfers; +pub use rebuild_go_colliders_reducer::rebuild_go_colliders; pub use receive_ai_event_condition_type::ReceiveAiEventCondition; pub use receive_emote_condition_type::ReceiveEmoteCondition; pub use record_region_load_reducer::record_region_load; @@ -2090,6 +2100,7 @@ pub enum Reducer { map_id: u32, creature_entry: u32, }, + DebugAssertGoCollision, DebugAssertUnreachableGoalStopsAtWall { character_guid: u64, }, @@ -2713,6 +2724,16 @@ pub enum Reducer { y_1: f32, z_1: f32, }, + DebugVmapRayInstance { + map: u32, + x_0: f32, + y_0: f32, + z_0: f32, + x_1: f32, + y_1: f32, + z_1: f32, + instance_id: u64, + }, DeleteCharacter { account_id: u64, request_actor: SessionActor, @@ -3509,6 +3530,7 @@ pub enum Reducer { ReapTransfers { schedule: TransferReaperSchedule, }, + RebuildGoColliders, RecordRegionLoad { map_id: u32, region_id: u32, @@ -3665,6 +3687,7 @@ impl __sdk::Reducer for Reducer { Reducer::DebugAssertBlinkClamp { .. } => "debug_assert_blink_clamp", Reducer::DebugAssertChaseStopsAtColumn { .. } => "debug_assert_chase_stops_at_column", Reducer::DebugAssertFloorSnap { .. } => "debug_assert_floor_snap", + Reducer::DebugAssertGoCollision => "debug_assert_go_collision", Reducer::DebugAssertUnreachableGoalStopsAtWall { .. } => { "debug_assert_unreachable_goal_stops_at_wall" } @@ -3845,6 +3868,7 @@ impl __sdk::Reducer for Reducer { } Reducer::DebugVmapAreaInfo { .. } => "debug_vmap_area_info", Reducer::DebugVmapRay { .. } => "debug_vmap_ray", + Reducer::DebugVmapRayInstance { .. } => "debug_vmap_ray_instance", Reducer::DeleteCharacter { .. } => "delete_character", Reducer::DiscardVmapGeneration { .. } => "discard_vmap_generation", Reducer::EnsureInstance { .. } => "ensure_instance", @@ -4016,6 +4040,7 @@ impl __sdk::Reducer for Reducer { Reducer::ReapMovementEvents { .. } => "reap_movement_events", Reducer::ReapSessions { .. } => "reap_sessions", Reducer::ReapTransfers { .. } => "reap_transfers", + Reducer::RebuildGoColliders => "rebuild_go_colliders", Reducer::RecordRegionLoad { .. } => "record_region_load", Reducer::RecordShardLoad { .. } => "record_shard_load", Reducer::ReleaseAccountClaim { .. } => "release_account_claim", @@ -4224,7 +4249,9 @@ Reducer::ClearPromotedLootRoll{ map_id: map_id.clone(), creature_entry: creature_entry.clone(), }), - Reducer::DebugAssertUnreachableGoalStopsAtWall{ + Reducer::DebugAssertGoCollision => __sats::bsatn::to_vec(&debug_assert_go_collision_reducer::DebugAssertGoCollisionArgs { + }), +Reducer::DebugAssertUnreachableGoalStopsAtWall{ character_guid, } => __sats::bsatn::to_vec(&debug_assert_unreachable_goal_stops_at_wall_reducer::DebugAssertUnreachableGoalStopsAtWallArgs { character_guid: character_guid.clone(), @@ -5341,6 +5368,25 @@ Reducer::DebugVerifyRangedLethalDamageFloorFixture{ x_1: x_1.clone(), y_1: y_1.clone(), z_1: z_1.clone(), +}), + Reducer::DebugVmapRayInstance{ + map, + x_0, + y_0, + z_0, + x_1, + y_1, + z_1, + instance_id, +} => __sats::bsatn::to_vec(&debug_vmap_ray_instance_reducer::DebugVmapRayInstanceArgs { + map: map.clone(), + x_0: x_0.clone(), + y_0: y_0.clone(), + z_0: z_0.clone(), + x_1: x_1.clone(), + y_1: y_1.clone(), + z_1: z_1.clone(), + instance_id: instance_id.clone(), }), Reducer::DeleteCharacter{ account_id, @@ -6769,7 +6815,9 @@ Reducer::PrepareVmapNavCoverage{ } => __sats::bsatn::to_vec(&reap_transfers_reducer::ReapTransfersArgs { schedule: schedule.clone(), }), - Reducer::RecordRegionLoad{ + Reducer::RebuildGoColliders => __sats::bsatn::to_vec(&rebuild_go_colliders_reducer::RebuildGoCollidersArgs { + }), +Reducer::RecordRegionLoad{ map_id, region_id, players, @@ -7108,6 +7156,7 @@ pub struct DbUpdate { game_gateway_lease: __sdk::TableUpdate, game_gateway_lease_reaper_schedule: __sdk::TableUpdate, game_gateway_session: __sdk::TableUpdate, + game_go_collider: __sdk::TableUpdate, game_go_model: __sdk::TableUpdate, game_gossip_menu: __sdk::TableUpdate, game_gossip_menu_profile: __sdk::TableUpdate, @@ -7637,6 +7686,9 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { "game_gateway_session" => db_update.game_gateway_session.append( game_gateway_session_table::parse_table_update(table_update)?, ), + "game_go_collider" => db_update + .game_go_collider + .append(game_go_collider_table::parse_table_update(table_update)?), "game_go_model" => db_update .game_go_model .append(game_go_model_table::parse_table_update(table_update)?), @@ -8631,6 +8683,9 @@ impl __sdk::DbUpdate for DbUpdate { &self.game_gateway_session, ) .with_updates_by_pk(|row| &row.entity_guid); + diff.game_go_collider = cache + .apply_diff_to_table::("game_go_collider", &self.game_go_collider) + .with_updates_by_pk(|row| &row.go_guid); diff.game_go_model = cache .apply_diff_to_table::("game_go_model", &self.game_go_model) .with_updates_by_pk(|row| &row.entry); @@ -9526,6 +9581,9 @@ impl __sdk::DbUpdate for DbUpdate { "game_gateway_session" => db_update .game_gateway_session .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "game_go_collider" => db_update + .game_go_collider + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "game_go_model" => db_update .game_go_model .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -10280,6 +10338,9 @@ impl __sdk::DbUpdate for DbUpdate { "game_gateway_session" => db_update .game_gateway_session .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "game_go_collider" => db_update + .game_go_collider + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "game_go_model" => db_update .game_go_model .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -10815,6 +10876,7 @@ pub struct AppliedDiff<'r> { game_gateway_lease: __sdk::TableAppliedDiff<'r, GatewayLease>, game_gateway_lease_reaper_schedule: __sdk::TableAppliedDiff<'r, GatewayLeaseReaperSchedule>, game_gateway_session: __sdk::TableAppliedDiff<'r, GatewaySession>, + game_go_collider: __sdk::TableAppliedDiff<'r, GoCollider>, game_go_model: __sdk::TableAppliedDiff<'r, GoModel>, game_gossip_menu: __sdk::TableAppliedDiff<'r, GossipMenu>, game_gossip_menu_profile: __sdk::TableAppliedDiff<'r, GossipMenuProfile>, @@ -11495,6 +11557,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.game_gateway_session, event, ); + callbacks.invoke_table_row_callbacks::( + "game_go_collider", + &self.game_go_collider, + event, + ); callbacks.invoke_table_row_callbacks::( "game_go_model", &self.game_go_model, @@ -12910,6 +12977,7 @@ impl __sdk::SpacetimeModule for RemoteModule { game_gateway_lease_table::register_table(client_cache); game_gateway_lease_reaper_schedule_table::register_table(client_cache); game_gateway_session_table::register_table(client_cache); + game_go_collider_table::register_table(client_cache); game_go_model_table::register_table(client_cache); game_gossip_menu_table::register_table(client_cache); game_gossip_menu_profile_table::register_table(client_cache); @@ -13159,6 +13227,7 @@ impl __sdk::SpacetimeModule for RemoteModule { "game_gateway_lease", "game_gateway_lease_reaper_schedule", "game_gateway_session", + "game_go_collider", "game_go_model", "game_gossip_menu", "game_gossip_menu_profile", diff --git a/gateway/src/stdb/bindings/rebuild_go_colliders_reducer.rs b/gateway/src/stdb/bindings/rebuild_go_colliders_reducer.rs new file mode 100644 index 000000000..172fde51d --- /dev/null +++ b/gateway/src/stdb/bindings/rebuild_go_colliders_reducer.rs @@ -0,0 +1,62 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct RebuildGoCollidersArgs {} + +impl From for super::Reducer { + fn from(args: RebuildGoCollidersArgs) -> Self { + Self::RebuildGoColliders + } +} + +impl __sdk::InModule for RebuildGoCollidersArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `rebuild_go_colliders`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait rebuild_go_colliders { + /// Request that the remote module invoke the reducer `rebuild_go_colliders` 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 [`rebuild_go_colliders:rebuild_go_colliders_then`] to run a callback after the reducer completes. + fn rebuild_go_colliders(&self) -> __sdk::Result<()> { + self.rebuild_go_colliders_then(|_, _| {}) + } + + /// Request that the remote module invoke the reducer `rebuild_go_colliders` 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 rebuild_go_colliders_then( + &self, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl rebuild_go_colliders for super::RemoteReducers { + fn rebuild_go_colliders_then( + &self, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp + .invoke_reducer_with_callback(RebuildGoCollidersArgs {}, callback) + } +} diff --git a/module/src/combat/engage.rs b/module/src/combat/engage.rs index f1969f7c9..9c79da1ed 100644 --- a/module/src/combat/engage.rs +++ b/module/src/combat/engage.rs @@ -655,6 +655,7 @@ pub(crate) fn apply_start_ranged_attack( if !crate::nav::has_los( ctx, attacker.map_id, + attacker.instance_id, (attacker.x, attacker.y, attacker.z), (target.x, target.y, target.z), ) { diff --git a/module/src/combat/swing.rs b/module/src/combat/swing.rs index 2be3c0d1e..47093a0aa 100644 --- a/module/src/combat/swing.rs +++ b/module/src/combat/swing.rs @@ -564,6 +564,7 @@ fn swing_blocked( if !crate::nav::has_los( ctx, attacker.map_id, + attacker.instance_id, (attacker.x, attacker.y, attacker.z), (target.x, target.y, target.z), ) { diff --git a/module/src/creatures/cycle/ctx.rs b/module/src/creatures/cycle/ctx.rs index f28384ae9..1b2ffd78a 100644 --- a/module/src/creatures/cycle/ctx.rs +++ b/module/src/creatures/cycle/ctx.rs @@ -358,7 +358,16 @@ impl IdleSink for CtxWorld<'_> { .guid() .find(guid) .map_or(to, |c| { - crate::nav::nav_step(self.ctx, c.map_id, (c.x, c.y), to, max_step, 0.0, c.z) + crate::nav::nav_step( + self.ctx, + c.map_id, + c.instance_id, + (c.x, c.y), + to, + max_step, + 0.0, + c.z, + ) }) } fn roll(&self) -> u32 { @@ -460,7 +469,13 @@ impl EngageSink for CtxWorld<'_> { .guid() .find(looker) .is_none_or(|c| { - crate::nav::has_los(self.ctx, c.map_id, (c.x, c.y, c.z), (at.x, at.y, at.z)) + crate::nav::has_los( + self.ctx, + c.map_id, + c.instance_id, + (c.x, c.y, c.z), + (at.x, at.y, at.z), + ) }) } fn engage(&mut self, creature: u64, victim: u64, pull: Pull) { diff --git a/module/src/creatures/cycle/harness.rs b/module/src/creatures/cycle/harness.rs index 160a26d3f..c9a69e690 100644 --- a/module/src/creatures/cycle/harness.rs +++ b/module/src/creatures/cycle/harness.rs @@ -5907,7 +5907,7 @@ fn the_production_adapter_is_the_pass_through_the_harness_assumes() { "match gait { Gait::Walk => constants::speeds::WALK, Gait::Run => ", "constants::speeds::RUN, }, ) } fn navigate(&self, guid: u64, to: (f32, f32), ", "max_step: f32) -> (f32, f32) { self.ctx .db .game_world_entity() .guid() ", - ".find(guid) .map_or(to, |c| { crate::nav::nav_step(self.ctx, c.map_id, (c.x, ", + ".find(guid) .map_or(to, |c| { crate::nav::nav_step(self.ctx, c.map_id, c.instance_id, (c.x, ", "c.y), to, max_step, 0.0, c.z) }) } fn roll(&self) -> u32 { self.ctx.random() } ", "fn aim_at_waypoint(&mut self, guid: u64, waypoint_id: u64) { let entities = ", "self.ctx.db.game_world_entity(); if let Some(mut e) = ", @@ -5946,7 +5946,7 @@ fn the_production_adapter_is_the_pass_through_the_harness_assumes() { "crate::faction::is_hostile(self.ctx, faction_template, other) } fn ", "line_of_sight(&self, looker: u64, at: Point) -> bool { self.ctx .db ", ".game_world_entity() .guid() .find(looker) .is_none_or(|c| { ", - "crate::nav::has_los(self.ctx, c.map_id, (c.x, c.y, c.z), (at.x, at.y, at.z)) ", + "crate::nav::has_los( self.ctx, c.map_id, c.instance_id, (c.x, c.y, c.z), (at.x, at.y, at.z), ) ", "}) } fn engage(&mut self, creature: u64, victim: u64, pull: Pull) { ", "crate::combat::arm_creature_engagement(self.ctx, creature, victim, pull == ", "Pull::Assisted); } fn engagements(&self) -> ", diff --git a/module/src/creatures/eventai/engine.rs b/module/src/creatures/eventai/engine.rs index 5f7227b12..b791d794e 100644 --- a/module/src/creatures/eventai/engine.rs +++ b/module/src/creatures/eventai/engine.rs @@ -1064,6 +1064,7 @@ impl EventAiWorld for DatabaseWorld<'_> { && crate::nav::has_los( self.ctx, first.map_id, + first.instance_id, (first.x, first.y, first.z), (second.x, second.y, second.z), ) diff --git a/module/src/creatures/spawn.rs b/module/src/creatures/spawn.rs index 7e139a7c7..90c3d051b 100644 --- a/module/src/creatures/spawn.rs +++ b/module/src/creatures/spawn.rs @@ -662,7 +662,14 @@ pub(crate) fn insert_creature_entity(ctx: &spacetimedb::ReducerContext, mut enti // Stand on a model floor (bridge, WMO interior deck) instead of the imported spawn.z // when one's imported at/below this spawn point — `floor_z` is `None` off vmap-slice/gate, so // an unimported map spawns byte-identical to before this line existed. - if let Some(floor) = crate::vmap::floor_z(ctx, entity.map_id, entity.x, entity.y, entity.z) { + if let Some(floor) = crate::vmap::floor_z( + ctx, + entity.map_id, + entity.instance_id, + entity.x, + entity.y, + entity.z, + ) { entity.z = entity.z.max(floor); } let payload = crate::hooks::CreatureSpawnPayload { diff --git a/module/src/creatures/tick/mod.rs b/module/src/creatures/tick/mod.rs index 824396be0..80af71d14 100644 --- a/module/src/creatures/tick/mod.rs +++ b/module/src/creatures/tick/mod.rs @@ -644,7 +644,7 @@ pub(crate) fn emit_creature_leg( set_leg_ends: bool, ) { // Ground-snap THIS leg's landing point (work-item 174) — one snap now covers every pass. - let nz = crate::terrain::snap_z(ctx, e.map_id, to.0, to.1, z_fallback); + let nz = crate::terrain::snap_z(ctx, e.map_id, e.instance_id, to.0, to.1, z_fallback); // REFUSE a non-finite leg (see `ai::finite_point`). Writing one makes the creature invisible to // this very tick — its grid cell casts to `i32::MIN`, so no active cell ever contains it again — // while `tick_melee` keeps swinging off the melee row. It becomes an unshakeable attacker. Loud, diff --git a/module/src/debug/instance.rs b/module/src/debug/instance.rs index 390d15cc5..26966cf21 100644 --- a/module/src/debug/instance.rs +++ b/module/src/debug/instance.rs @@ -185,8 +185,8 @@ pub fn debug_nav_leg(ctx: &ReducerContext, map: u32, x0: f32, y0: f32, x1: f32, // LoS/collision endpoints stand on the ground like real units (falls back to z=0 off-slice). let z0 = crate::terrain::ground_z(ctx, map, x0, y0).unwrap_or(0.0); let z1 = crate::terrain::ground_z(ctx, map, x1, y1).unwrap_or(0.0); - let step = crate::nav::nav_step(ctx, map, (x0, y0), (x1, y1), 4.0, 0.0, z0); - let los = crate::nav::has_los(ctx, map, (x0, y0, z0), (x1, y1, z1)); + let step = crate::nav::nav_step(ctx, map, 0, (x0, y0), (x1, y1), 4.0, 0.0, z0); + let los = crate::nav::has_los(ctx, map, 0, (x0, y0, z0), (x1, y1, z1)); let raw = crate::nav::debug_find_leg(ctx, map, (x0, y0), (x1, y1)); log::info!( "nav leg ({x0:.1},{y0:.1})->({x1:.1},{y1:.1}) map {map}: nav_enabled={enabled} first_step=({:.2},{:.2}) has_los={los} raw={raw}", @@ -245,11 +245,7 @@ pub fn debug_set_nav_coverage_enabled(ctx: &ReducerContext, enabled: bool) -> Re Ok(()) } -/// 521 spot-probe: cast an exact vmap ray from (x0,y0,z0) to (x1,y1,z1) on `map`, BOTH flavors — -/// LoS (WMO-only) and collision (WMO + M2 doodads). Analogue of `debug_nav_leg`; vmap has no -/// client-visible readback, so this is the server-side verification hook (decision). Logs -/// hit/miss + the first-hit point for each flavor so a doodad-vs-wall distinction is directly -/// observable: a doodad blocks `collision` but leaves `los` clear. +/// Probe both ray flavors in instance 0. Preserve the original Operator command signature. #[reducer] #[allow(clippy::too_many_arguments)] // A reducer's arguments are its call signature. pub fn debug_vmap_ray( @@ -262,12 +258,30 @@ pub fn debug_vmap_ray( y1: f32, z1: f32, ) { - let enabled = crate::vmap::vmap_enabled(ctx, map); + debug_vmap_ray_instance(ctx, map, x0, y0, z0, x1, y1, z1, 0); +} + +/// Probe both ray flavors in one instance, independent of the gameplay Gate. +/// Static doodads affect collision only; closed DOOR/BUTTON geometry affects both flavors. +#[reducer] +#[allow(clippy::too_many_arguments)] // A reducer's arguments are its call signature. +pub fn debug_vmap_ray_instance( + ctx: &ReducerContext, + map: u32, + x0: f32, + y0: f32, + z0: f32, + x1: f32, + y1: f32, + z1: f32, + instance_id: u64, +) { + let enabled = crate::vmap::rays_enabled(ctx); let a = [x0, y0, z0]; let b = [x1, y1, z1]; - let (los, collision) = crate::vmap::probe_rays(ctx, map, a, b); + let (los, collision) = crate::vmap::probe_rays(ctx, map, instance_id, a, b); log::info!( - "vmap ray ({x0:.1},{y0:.1},{z0:.1})->({x1:.1},{y1:.1},{z1:.1}) map {map}: vmap_enabled={enabled} los={los:?} collision={collision:?}" + "vmap ray ({x0:.1},{y0:.1},{z0:.1})->({x1:.1},{y1:.1},{z1:.1}) map {map} instance {instance_id}: vmap_enabled={enabled} los={los:?} collision={collision:?}" ); } @@ -279,8 +293,8 @@ pub fn debug_vmap_ray( pub fn debug_floor_probe(ctx: &ReducerContext, map: u32, x: f32, y: f32, probe_z: f32) { let enabled = crate::vmap::vmap_enabled(ctx, map); let ground = crate::terrain::ground_z(ctx, map, x, y); - let floor = crate::vmap::probe_floor_z(ctx, map, x, y, probe_z); - let snapped = crate::terrain::snap_z(ctx, map, x, y, probe_z); + let floor = crate::vmap::probe_floor_z(ctx, map, 0, x, y, probe_z); + let snapped = crate::terrain::snap_z(ctx, map, 0, x, y, probe_z); log::info!( "floor probe ({x:.1},{y:.1}) map {map} probe_z={probe_z:.2}: vmap_enabled={enabled} ground_z={ground:?} floor_z={floor:?} snap_z={snapped:.2}" ); @@ -409,9 +423,9 @@ pub fn debug_assert_floor_snap( } // (1) the raw floor probe, from above the deck looking down. - let floor = crate::vmap::floor_z(ctx, map_id, ORIGIN.0, ORIGIN.1, FLOOR_Z + 5.0); + let floor = crate::vmap::floor_z(ctx, map_id, 0, ORIGIN.0, ORIGIN.1, FLOOR_Z + 5.0); // (2) the combined snap a movement/targeting call site actually uses. - let snapped = crate::terrain::snap_z(ctx, map_id, ORIGIN.0, ORIGIN.1, TERRAIN_FALLBACK); + let snapped = crate::terrain::snap_z(ctx, map_id, 0, ORIGIN.0, ORIGIN.1, TERRAIN_FALLBACK); let ground = crate::terrain::ground_z(ctx, map_id, ORIGIN.0, ORIGIN.1); // (3) spawn a creature AT the terrain-less fallback z (i.e. what an imported spawn row with @@ -776,6 +790,7 @@ pub fn debug_assert_chase_stops_at_column( let stepped = crate::nav::nav_step( ctx, map_id, + start.instance_id, (ORIGIN.0, ORIGIN.1), (DEST_X, ORIGIN.1), STEP_YD, @@ -895,7 +910,7 @@ pub fn debug_assert_unreachable_goal_stops_at_wall( } } - let stepped = crate::nav::nav_step(ctx, map_id, cur, dest, 100.0, 0.0, 0.0); + let stepped = crate::nav::nav_step(ctx, map_id, start.instance_id, cur, dest, 100.0, 0.0, 0.0); // Cleanup FIRST — never leave synthetic nav data or flipped flags behind, even on assert failure. nav_chunks.key().delete(key); @@ -991,9 +1006,9 @@ pub fn debug_bench_los( let angle = TAU * (d as f32) / (directions.max(1) as f32); let (bx, by, bz) = (cx + radius * angle.cos(), cy + radius * angle.sin(), *cz); let blocked = if exact { - crate::vmap::los_ray(ctx, map, [*cx, *cy, *cz], [bx, by, bz]).is_some() + crate::vmap::los_ray(ctx, map, 0, [*cx, *cy, *cz], [bx, by, bz]).is_some() } else { - !crate::nav::has_los(ctx, map, (*cx, *cy, *cz), (bx, by, bz)) + !crate::nav::has_los(ctx, map, 0, (*cx, *cy, *cz), (bx, by, bz)) }; queries += 1; if blocked { @@ -1054,7 +1069,7 @@ pub fn debug_bench_collision_gate( for d in 0..directions { let angle = TAU * (d as f32) / (directions.max(1) as f32); let dest = (cx + radius * angle.cos(), cy + radius * angle.sin()); - let stepped = crate::nav::nav_step(ctx, map, (*cx, *cy), dest, radius, 0.0, *cz); + let stepped = crate::nav::nav_step(ctx, map, 0, (*cx, *cy), dest, radius, 0.0, *cz); queries += 1; if (stepped.0 - dest.0).abs() > 0.01 || (stepped.1 - dest.1).abs() > 0.01 { gated += 1; diff --git a/module/src/debug/mod.rs b/module/src/debug/mod.rs index 56eea5680..70ccc2607 100644 --- a/module/src/debug/mod.rs +++ b/module/src/debug/mod.rs @@ -758,7 +758,8 @@ pub fn debug_spawn_gameobject( // Clear any prior Pick-Lock unlock (work-item 119) for this DERIVED guid so a re-spawn of a locked // template is deterministically LOCKED again (the verify's negative case relies on this). ctx.db.game_gameobject_unlocked().go_guid().delete(guid); - ctx.db + let go = ctx + .db .game_gameobject() .insert(crate::gameobject::GameObject { guid, @@ -784,6 +785,7 @@ pub fn debug_spawn_gameobject( rotation_2: 0.0, rotation_3: 0.0, }); + crate::go_collider::register(ctx, &go); Ok(()) } diff --git a/module/src/gameobject.rs b/module/src/gameobject.rs index 389c2464f..171cd2872 100644 --- a/module/src/gameobject.rs +++ b/module/src/gameobject.rs @@ -946,6 +946,7 @@ pub(crate) fn despawn_from_relay(ctx: &ReducerContext, go_guid: u64) -> Result<( .game_gameobject_trap_cooldown() .go_guid() .delete(go_guid); + crate::go_collider::remove(ctx, go_guid); ctx.db.game_gameobject().guid().delete(go_guid); Ok(()) } @@ -1098,6 +1099,7 @@ pub fn import_gameobjects(ctx: &ReducerContext, packed: String) -> Result<(), St let gos = ctx.db.game_gameobject(); let guids: Vec = gos.iter().map(|g| g.guid).collect(); for g in guids { + crate::go_collider::remove(ctx, g); gos.guid().delete(g); } if load_go_batch(ctx, &packed)? == 0 { @@ -1145,29 +1147,31 @@ fn load_go_batch(ctx: &ReducerContext, packed: &str) -> Result { // also makes it structurally impossible for `grid_x`, `grid_y` and `cell` below to be // derived from different coordinates. let (gx_src, gy_src) = (pf32(f[3])?, pf32(f[4])?); - gos.try_insert(GameObject { - guid: pu64(f[0])?, - template_entry: pu32(f[1])?, - map_id: pu32(f[2])?, - x: pf32(f[3])?, - y: pf32(f[4])?, - z: pf32(f[5])?, - orientation: pf32(f[6])?, - state: pu8(f[7])?, - created_at: now, - respawn_at_micros: 0, // a freshly-imported node is ready (no pending respawn) - instance_id: 0, // imported static rows are open-world (dungeon copies are runtime, 190 slice 2) - // note: the new respawn_secs/gather_gray cols live on the TEMPLATE (GameObjectTemplate), not on - // this SPAWN row — so this GameObject literal is otherwise unchanged this slice., - grid_x: lyracore_shared::spatial::grid_cell(gx_src, gy_src).0, - grid_y: lyracore_shared::spatial::grid_cell(gx_src, gy_src).1, - cell: lyracore_shared::spatial::cell_id_at(gx_src, gy_src), - rotation_0: pf32(f[8])?, - rotation_1: pf32(f[9])?, - rotation_2: pf32(f[10])?, - rotation_3: pf32(f[11])?, - }) - .map_err(|e| format!("gameobject insert failed (dup guid?): {e}"))?; + let go = gos + .try_insert(GameObject { + guid: pu64(f[0])?, + template_entry: pu32(f[1])?, + map_id: pu32(f[2])?, + x: pf32(f[3])?, + y: pf32(f[4])?, + z: pf32(f[5])?, + orientation: pf32(f[6])?, + state: pu8(f[7])?, + created_at: now, + respawn_at_micros: 0, // a freshly-imported node is ready (no pending respawn) + instance_id: 0, // imported static rows are open-world (dungeon copies are runtime, 190 slice 2) + // note: the new respawn_secs/gather_gray cols live on the TEMPLATE (GameObjectTemplate), not on + // this SPAWN row — so this GameObject literal is otherwise unchanged this slice., + grid_x: lyracore_shared::spatial::grid_cell(gx_src, gy_src).0, + grid_y: lyracore_shared::spatial::grid_cell(gx_src, gy_src).1, + cell: lyracore_shared::spatial::cell_id_at(gx_src, gy_src), + rotation_0: pf32(f[8])?, + rotation_1: pf32(f[9])?, + rotation_2: pf32(f[10])?, + rotation_3: pf32(f[11])?, + }) + .map_err(|e| format!("gameobject insert failed (dup guid?): {e}"))?; + crate::go_collider::register(ctx, &go); loaded += 1; } Ok(loaded) diff --git a/module/src/go_collider.rs b/module/src/go_collider.rs new file mode 100644 index 000000000..e9f040aaf --- /dev/null +++ b/module/src/go_collider.rs @@ -0,0 +1,305 @@ +//! Closed DOOR and BUTTON collision, scoped to one map and instance. + +use crate::gameobject::{game_gameobject, game_gameobject_template, GameObject}; +use crate::go_model::game_go_model; +use lyracore_shared::vmap::{decode, segment_tri_hit}; +use spacetimedb::{reducer, table, ReducerContext, Table}; + +#[table(accessor = game_go_collider, index(accessor = by_partition, btree(columns = [map_id, instance_id])))] +pub struct GoCollider { + #[primary_key] + pub go_guid: u64, + pub map_id: u32, + pub instance_id: u64, + pub entry: u32, + pub x: f32, + pub y: f32, + pub z: f32, + pub radius_world: f32, +} + +/// Replace derived registration from the current spawn and model. Open doors remain registered. +pub(crate) fn register(ctx: &ReducerContext, go: &GameObject) { + remove(ctx, go.guid); + let Some(template) = ctx + .db + .game_gameobject_template() + .entry() + .find(go.template_entry) + else { + return; + }; + if !matches!(template.type_id, 0 | 1) { + return; + } + let Some(model) = ctx.db.game_go_model().entry().find(go.template_entry) else { + return; + }; + // Older imports stored an AABB-centered radius without its center. Derive the origin + // bound from vertices so off-center meshes cannot miss the broad phase. + let Ok(tris) = decode(&model.blob) else { + return; + }; + let radius_world = tris + .iter() + .flat_map(|t| t.verts) + .map(|v| dot(v, v).sqrt()) + .fold(0.0_f32, f32::max) + * model.scale; + if !radius_world.is_finite() + || model.scale <= 0.0 + || radius_world < 0.0 + || ![go.x, go.y, go.z].iter().all(|v| v.is_finite()) + { + return; + } + ctx.db.game_go_collider().insert(GoCollider { + go_guid: go.guid, + map_id: go.map_id, + instance_id: go.instance_id, + entry: go.template_entry, + x: go.x, + y: go.y, + z: go.z, + radius_world, + }); +} + +pub(crate) fn remove(ctx: &ReducerContext, guid: u64) { + ctx.db.game_go_collider().go_guid().delete(guid); +} + +/// Operator reconciliation covers every partition hosted by this Shard. +#[reducer] +pub fn rebuild_go_colliders(ctx: &ReducerContext) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + let guids: Vec<_> = ctx + .db + .game_go_collider() + .iter() + .map(|c| c.go_guid) + .collect(); + for guid in guids { + remove(ctx, guid); + } + for go in ctx.db.game_gameobject().iter() { + register(ctx, &go); + } + Ok(()) +} + +fn dot(a: [f32; 3], b: [f32; 3]) -> f32 { + a.iter().zip(b).map(|(x, y)| x * y).sum() +} +fn sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { + std::array::from_fn(|i| a[i] - b[i]) +} +fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { + [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ] +} + +/// Conservative broad phase, including tangency and a point segment inside the sphere. +fn overlaps_sphere(a: [f32; 3], b: [f32; 3], center: [f32; 3], radius: f32) -> bool { + let dir = sub(b, a); + let len_sq = dot(dir, dir); + let t = if len_sq > 0.0 { + (dot(sub(center, a), dir) / len_sq).clamp(0.0, 1.0) + } else { + 0.0 + }; + let nearest = std::array::from_fn(|i| a[i] + t * dir[i]); + let offset = sub(nearest, center); + dot(offset, offset) <= radius * radius + 0.0001 +} + +/// Inverse of scale, normalized spawn quaternion and translation. Zero quaternion means yaw. +fn local_point( + point: [f32; 3], + position: [f32; 3], + rotation: [f32; 4], + yaw: f32, + scale: f32, +) -> Option<[f32; 3]> { + if !scale.is_finite() || scale <= 0.0 { + return None; + } + let q = if rotation == [0.0; 4] { + [0.0, 0.0, (yaw * 0.5).sin(), (yaw * 0.5).cos()] + } else { + rotation + }; + let norm = q.iter().map(|v| v * v).sum::().sqrt(); + if !norm.is_finite() || norm == 0.0 { + return None; + } + let v = sub(point, position); + let axis = [-q[0] / norm, -q[1] / norm, -q[2] / norm]; + let uv = cross(axis, v); + let uuv = cross(axis, uv); + let local = std::array::from_fn(|i| (v[i] + 2.0 * (q[3] / norm * uv[i] + uuv[i])) / scale); + local.iter().all(|v| v.is_finite()).then_some(local) +} + +/// Nearest world hit. Triangle class is irrelevant for state-controlled geometry. +pub(crate) fn ray( + ctx: &ReducerContext, + map_id: u32, + instance_id: u64, + a: [f32; 3], + b: [f32; 3], +) -> Option<[f32; 3]> { + let mut nearest: Option = None; + for collider in ctx + .db + .game_go_collider() + .by_partition() + .filter((map_id, instance_id)) + { + if !overlaps_sphere( + a, + b, + [collider.x, collider.y, collider.z], + collider.radius_world, + ) { + continue; + } + let Some(go) = ctx.db.game_gameobject().guid().find(collider.go_guid) else { + continue; + }; + if go.state != 0 + || go.map_id != map_id + || go.instance_id != instance_id + || go.template_entry != collider.entry + { + continue; + } + let Some(model) = ctx.db.game_go_model().entry().find(collider.entry) else { + continue; + }; + let pos = [go.x, go.y, go.z]; + let rotation = [go.rotation_0, go.rotation_1, go.rotation_2, go.rotation_3]; + let Some(local_a) = local_point(a, pos, rotation, go.orientation, model.scale) else { + continue; + }; + let Some(local_b) = local_point(b, pos, rotation, go.orientation, model.scale) else { + continue; + }; + let Ok(tris) = decode(&model.blob) else { + continue; + }; + for tri in tris { + if let Some(t) = segment_tri_hit(local_a, local_b, tri.verts) { + nearest = Some(nearest.map_or(t, |old| old.min(t))); + } + } + } + nearest.map(|t| std::array::from_fn(|i| a[i] + t * (b[i] - a[i]))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sphere_rejects_only_segments_outside_the_bound() { + assert!(overlaps_sphere( + [-2.0, 1.0, 0.0], + [2.0, 1.0, 0.0], + [0.0; 3], + 1.0 + )); + assert!(overlaps_sphere([0.0; 3], [0.0; 3], [0.0; 3], 1.0)); + assert!(!overlaps_sphere([2.0; 3], [2.0; 3], [0.0; 3], 1.0)); + assert!(!overlaps_sphere( + [-2.0, 2.0, 0.0], + [2.0, 2.0, 0.0], + [0.0; 3], + 1.0 + )); + assert!(!overlaps_sphere( + [2.0, 0.0, 0.0], + [3.0, 0.0, 0.0], + [0.0; 3], + 1.0 + )); + } + + fn near(actual: [f32; 3], expected: [f32; 3]) { + for i in 0..3 { + assert!( + (actual[i] - expected[i]).abs() < 0.0001, + "{actual:?} != {expected:?}" + ); + } + } + + #[test] + fn local_transform_undoes_translation_yaw_and_scale() { + near( + local_point( + [10.0, 24.0, 36.0], + [10.0, 20.0, 30.0], + [0.0; 4], + std::f32::consts::FRAC_PI_2, + 2.0, + ) + .unwrap(), + [2.0, 0.0, 3.0], + ); + } + + #[test] + fn stored_quaternion_overrides_yaw_and_rotates_out_of_the_horizontal_plane() { + let q = [1.0, 0.0, 0.0, 1.0]; // Normalizes to a quarter turn about X. + near( + local_point([2.0, -6.0, 4.0], [0.0; 3], q, 2.0, 2.0).unwrap(), + [1.0, 2.0, 3.0], + ); + } + + #[test] + fn transformed_segment_preserves_the_world_hit_parameter() { + let pos = [10.0, 20.0, 30.0]; + let a = local_point( + [10.0, 16.0, 30.0], + pos, + [0.0; 4], + std::f32::consts::FRAC_PI_2, + 2.0, + ) + .unwrap(); + let b = local_point( + [10.0, 24.0, 30.0], + pos, + [0.0; 4], + std::f32::consts::FRAC_PI_2, + 2.0, + ) + .unwrap(); + let tri = [[0.0, -1.0, -1.0], [0.0, 1.0, -1.0], [0.0, 0.0, 1.0]]; + assert!((segment_tri_hit(a, b, tri).unwrap() - 0.5).abs() < 0.0001); + } + + #[test] + fn invalid_transforms_do_not_produce_hits() { + for scale in [0.0, -1.0, f32::NAN, f32::INFINITY] { + assert!(local_point([0.0; 3], [0.0; 3], [0.0; 4], 0.0, scale).is_none()); + } + assert!(local_point([0.0; 3], [0.0; 3], [f32::NAN; 4], 0.0, 1.0).is_none()); + } + + #[test] + fn ray_merge_chooses_the_nearest_source_in_both_directions() { + use crate::vmap::nearest_hit; + let near = Some([2.0, 0.0, 0.0]); + let far = Some([8.0, 0.0, 0.0]); + assert_eq!(nearest_hit([0.0; 3], near, far), near); + assert_eq!(nearest_hit([0.0; 3], far, near), near); + assert_eq!(nearest_hit([10.0, 0.0, 0.0], near, far), far); + assert_eq!(nearest_hit([0.0; 3], None, near), near); + } +} diff --git a/module/src/go_collider_fixture.rs b/module/src/go_collider_fixture.rs new file mode 100644 index 000000000..417518f68 --- /dev/null +++ b/module/src/go_collider_fixture.rs @@ -0,0 +1,375 @@ +//! Synthetic durable verification. Run only on an isolated fixture Shard. + +use crate::game_world_entity; +use crate::gameobject::game_gameobject; +use crate::go_collider::game_go_collider; +use crate::go_model::game_go_model; +use lyracore_shared::vmap::{encode, TriClass, VmapTri}; +use spacetimedb::{reducer, ReducerContext, Table}; + +const MAP: u32 = 36; +const ENTRY: u32 = 509_113; +// The ray misses the spawn origin, so an incorrectly centered tiny radius would discard it. +const A: [f32; 3] = [-4.0, 0.5, 0.0]; +const B: [f32; 3] = [4.0, 0.5, 0.0]; + +fn require(condition: bool, reason: &str) -> Result<(), String> { + condition.then_some(()).ok_or_else(|| reason.to_string()) +} + +fn rays(ctx: &ReducerContext, instance: u64, expected_x: Option) -> Result<(), String> { + for hit in [ + crate::vmap::los_ray(ctx, MAP, instance, A, B), + crate::vmap::collision_ray(ctx, MAP, instance, A, B), + ] { + require( + match (hit, expected_x) { + (None, None) => true, + (Some(hit), Some(x)) => (hit[0] - x).abs() < 0.001, + _ => false, + }, + &format!("instance {instance}: expected {expected_x:?}, got {hit:?}"), + )?; + } + Ok(()) +} + +fn door(ctx: &ReducerContext, instance: u64) -> Result { + let row = ctx + .db + .game_go_collider() + .by_partition() + .filter((MAP, instance)) + .find(|r| r.entry == ENTRY) + .ok_or_else(|| format!("missing collider in {instance}"))?; + ctx.db + .game_gameobject() + .guid() + .find(row.go_guid) + .ok_or_else(|| "missing door".to_string()) +} + +fn blink(ctx: &ReducerContext, instance: u64, expected_x: f32) -> Result<(), String> { + crate::debug::debug_spawn_player_entity(ctx, 1)?; + let mut caster = ctx + .db + .game_world_entity() + .guid() + .find(1) + .ok_or("missing fixture Character")?; + caster.map_id = MAP; + caster.instance_id = instance; + caster.x = A[0]; + caster.y = A[1]; + caster.z = A[2]; + caster.orientation = 0.0; + (caster.grid_x, caster.grid_y) = lyracore_shared::spatial::grid_cell(caster.x, caster.y); + caster.cell = lyracore_shared::spatial::cell_id_at(caster.x, caster.y); + ctx.db.game_world_entity().guid().update(caster); + crate::spell::blink_forward(ctx, 1, 8.0); + let landed = ctx + .db + .game_world_entity() + .guid() + .find(1) + .ok_or("Blink removed the Character")?; + require( + landed.instance_id == instance && (landed.x - expected_x).abs() < 0.001, + "Blink crossed the closed door or lost instance identity", + )?; + ctx.db.game_world_entity().guid().delete(1); + Ok(()) +} + +fn package_claim(ctx: &ReducerContext, claim: &str) -> Result<(), String> { + let packed = format!( + r#"{{"version":1,"package":"fixture.collision","source_hash":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","claims":[{claim}]}}"# + ); + crate::package_import::apply_package_deltas(ctx, "gameobjects".to_string(), packed) +} + +fn package_lifecycle(ctx: &ReducerContext) -> Result<(), String> { + let spawn_key = format!( + r#""table":"game_gameobject","key":{{"map_id":{MAP},"spawn_id":{ENTRY}}},"operation":"update""# + ); + package_claim( + ctx, + &format!(r#"{{{spawn_key},"fields":{{"x":{{"type":"f32","value":40.0}}}}}}"#), + )?; + rays(ctx, 0, None)?; + for hit in [ + crate::vmap::los_ray(ctx, MAP, 0, [36.0, 0.5, 0.0], [44.0, 0.5, 0.0]), + crate::vmap::collision_ray(ctx, MAP, 0, [36.0, 0.5, 0.0], [44.0, 0.5, 0.0]), + ] { + require( + hit.is_some_and(|p| (p[0] - 41.0).abs() < 0.001), + "Package move left the collider at its old position", + )?; + } + package_claim( + ctx, + &format!(r#"{{{spawn_key},"fields":{{"x":{{"type":"f32","value":0.0}}}}}}"#), + )?; + for (type_id, expected) in [(3, None), (0, Some(1.0))] { + package_claim( + ctx, + &format!( + r#"{{"table":"game_gameobject_template","key":{{"entry":{ENTRY}}},"operation":"update","fields":{{"type_id":{{"type":"u8","value":{type_id}}}}}}}"# + ), + )?; + rays(ctx, 0, expected)?; + } + let inserted = format!( + r#"{{"table":"game_gameobject","key":{{"map_id":{MAP},"spawn_id":16000113}},"operation":"insert","fields":{{"template_entry":{{"type":"u32","value":{ENTRY}}},"x":{{"type":"f32","value":-2.0}},"y":{{"type":"f32","value":0.0}},"z":{{"type":"f32","value":0.0}},"orientation":{{"type":"f32","value":0.0}},"state":{{"type":"u8","value":0}},"rotation_0":{{"type":"f32","value":0.0}},"rotation_1":{{"type":"f32","value":0.0}},"rotation_2":{{"type":"f32","value":0.0}},"rotation_3":{{"type":"f32","value":0.0}}}}}}"# + ); + for _ in 0..2 { + package_claim(ctx, &inserted)?; + rays(ctx, 0, Some(-1.0))?; + require( + ctx.db + .game_go_collider() + .by_partition() + .filter((MAP, 0u64)) + .count() + == 2, + "Package replay duplicated a collider", + )?; + } + crate::package_import::apply_package_deltas(ctx, "gameobjects".to_string(), String::new())?; + rays(ctx, 0, Some(1.0))?; + require( + ctx.db + .game_go_collider() + .by_partition() + .filter((MAP, 0u64)) + .count() + == 1, + "Package clear retained a collider", + ) +} + +fn packed_model(entry: u32, scale: f32) -> String { + // Off-center local geometry exposes an incorrect spawn-centered use of an AABB radius. + let tris = [VmapTri { + verts: [[1.0, -2.0, -2.0], [1.0, 2.0, -2.0], [1.0, 0.0, 2.0]], + class: TriClass::M2, + }]; + let blob = encode(&tris); + let hex: String = blob.iter().map(|b| format!("{b:02x}")).collect(); + format!("{entry},{scale},0.01,{hex}") +} + +#[reducer] +pub fn debug_assert_go_collision(ctx: &ReducerContext) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + require( + !crate::vmap::vmap_enabled(ctx, MAP), + "fixture requires no active static generation on map 36", + )?; + crate::debug::debug_set_vmap_enabled(ctx, true)?; + crate::go_model::import_go_models(ctx, packed_model(ENTRY, 1.0))?; + crate::debug::debug_spawn_gameobject(ctx, ENTRY, 0, 0, 0, MAP, 0.0, 0.0, 0.0, 0, 0, 0, 0)?; + rays(ctx, 0, Some(1.0))?; + crate::go_collider::rebuild_go_colliders(ctx)?; + crate::go_collider::rebuild_go_colliders(ctx)?; + require( + ctx.db + .game_go_collider() + .by_partition() + .filter((MAP, 0u64)) + .count() + == 1, + "rebuild duplicated the collider", + )?; + require( + !crate::nav::has_los(ctx, MAP, 0, (-4.0, 0.0, 0.0), (4.0, 0.0, 0.0)), + "nav sight bypassed dynamic geometry", + )?; + let step = crate::nav::nav_step(ctx, MAP, 0, (-4.0, 0.0), (4.0, 0.0), 8.0, 0.0, 0.0); + require( + step.0.abs() < 0.001, + "movement did not stop one yard before the door", + )?; + crate::debug::debug_set_vmap_enabled(ctx, false)?; + rays(ctx, 0, None)?; + crate::debug::debug_set_vmap_enabled(ctx, true)?; + + let nearer_guid = (0xF110u64 << 48) | u64::from(ENTRY + 99); + crate::gameobject::import_gameobjects_append( + ctx, + format!("{nearer_guid},{ENTRY},{MAP},-2,0,0,0,0,0,0,0,0"), + )?; + rays(ctx, 0, Some(-1.0))?; + for hit in [ + crate::vmap::los_ray(ctx, MAP, 0, B, A), + crate::vmap::collision_ray(ctx, MAP, 0, B, A), + ] { + require( + hit.is_some_and(|p| (p[0] - 1.0).abs() < 0.001), + "dynamic rows did not select their nearest hit", + )?; + } + crate::gameobject::despawn_from_relay(ctx, nearer_guid)?; + + package_lifecycle(ctx)?; + let first = crate::instance::create_instance(ctx, MAP, 0)?; + let sibling = crate::instance::create_instance(ctx, MAP, 0)?; + rays(ctx, first, Some(1.0))?; + rays(ctx, sibling, Some(1.0))?; + require( + crate::vmap::los_ray(ctx, 1, first, A, B).is_none(), + "map isolation failed", + )?; + blink(ctx, first, 0.0)?; + crate::encounter::open_door(ctx, ENTRY, first)?; + blink(ctx, first, 4.0)?; + rays(ctx, first, None)?; + rays(ctx, sibling, Some(1.0))?; + rays(ctx, 0, Some(1.0))?; + let mut closed = door(ctx, first)?; + closed.state = 0; + ctx.db.game_gameobject().guid().update(closed); + rays(ctx, first, Some(1.0))?; + + // Static and dynamic hits compete by distance, independent of insertion order. + { + use crate::vmap::{ + game_vmap_generation, game_vmap_generation_chunk, VmapGeneration, VmapGenerationChunk, + }; + let generation_id = 509_113; + ctx.db.game_vmap_generation().insert(VmapGeneration { + id: generation_id, + map_id: MAP, + state: 2, + expected_chunks: 1, + accepted_chunks: 1, + expected_bytes: 0, + manifest_digest: vec![], + source_identity: "synthetic".to_string(), + selection_identity: "synthetic".to_string(), + }); + let cx = lyracore_shared::terrain::cell_index(-1.0).ok_or("missing cell")?; + let cy = lyracore_shared::terrain::cell_index(A[1]).ok_or("missing cell")?; + let chunk = ctx + .db + .game_vmap_generation_chunk() + .insert(VmapGenerationChunk { + id: 0, + generation_id, + shard_ordinal: 0, + key: lyracore_shared::terrain::cell_key(MAP, cx, cy), + map_id: MAP, + cell_x: cx, + cell_y: cy, + blob: encode(&[VmapTri { + verts: [[-1.0, -2.0, -2.0], [-1.0, 2.0, -2.0], [-1.0, 0.0, 2.0]], + class: TriClass::Wmo { + group_id: 0, + mogp_flags: 0, + }, + }]), + }); + rays(ctx, first, Some(-1.0))?; + for hit in [ + crate::vmap::los_ray(ctx, MAP, first, B, A), + crate::vmap::collision_ray(ctx, MAP, first, B, A), + ] { + require( + hit.is_some_and(|p| (p[0] - 1.0).abs() < 0.001), + "reverse ray failed to choose dynamic hit before static wall", + )?; + } + ctx.db.game_vmap_generation_chunk().id().delete(chunk.id); + ctx.db.game_vmap_generation().id().delete(generation_id); + } + + // Model replacement refreshes every registered instance and its broad-phase bound. + crate::go_model::import_go_models_append(ctx, packed_model(ENTRY, 2.0))?; + rays(ctx, first, Some(2.0))?; + rays(ctx, sibling, Some(2.0))?; + let removed = door(ctx, first)?.guid; + crate::gameobject::despawn_from_relay(ctx, removed)?; + rays(ctx, first, None)?; + require( + ctx.db.game_go_collider().go_guid().find(removed).is_none(), + "despawn retained collider", + )?; + let sibling_guid = door(ctx, sibling)?.guid; + crate::instance::teardown_instance(ctx, sibling); + rays(ctx, sibling, None)?; + require( + ctx.db + .game_go_collider() + .go_guid() + .find(sibling_guid) + .is_none(), + "instance reap retained collider", + )?; + crate::instance::teardown_instance(ctx, first); + + // The durable ray uses the spawn's current yaw/quaternion, translation and model scale. + let mut transformed = door(ctx, 0)?; + transformed.x = 10.0; + transformed.y = 20.0; + transformed.z = 30.0; + transformed.orientation = std::f32::consts::FRAC_PI_2; + crate::go_collider::register(ctx, &transformed); + ctx.db.game_gameobject().guid().update(transformed); + for hit in [ + crate::vmap::los_ray(ctx, MAP, 0, [10.0, 16.0, 30.0], [10.0, 24.0, 30.0]), + crate::vmap::collision_ray(ctx, MAP, 0, [10.0, 16.0, 30.0], [10.0, 24.0, 30.0]), + ] { + require( + hit.is_some_and(|p| (p[1] - 22.0).abs() < 0.001), + "translated yaw and scale did not place the door at y=22", + )?; + } + let mut transformed = door(ctx, 0)?; + transformed.rotation_1 = 1.0; + transformed.rotation_3 = 1.0; + ctx.db.game_gameobject().guid().update(transformed); + require( + crate::vmap::floor_z(ctx, MAP, 0, 10.0, 20.0, 30.0) + .is_some_and(|z| (z - 28.0).abs() < 0.001), + "quaternion floor probe missed scaled door", + )?; + + // A stale registry row cannot resurrect a deleted GameObject. + let source = door(ctx, 0)?.guid; + ctx.db.game_gameobject().guid().delete(source); + for hit in [ + crate::vmap::los_ray(ctx, MAP, 0, [10.0, 20.0, 32.0], [10.0, 20.0, 24.0]), + crate::vmap::collision_ray(ctx, MAP, 0, [10.0, 20.0, 32.0], [10.0, 20.0, 24.0]), + ] { + require(hit.is_none(), "an orphan collider still blocked the ray")?; + } + crate::go_collider::rebuild_go_colliders(ctx)?; + require( + ctx.db.game_go_collider().go_guid().find(source).is_none(), + "rebuild retained orphan", + )?; + + // Both supported types register; an unrelated prop with a model does not. + for (entry, type_id, expected) in [(ENTRY + 1, 1, true), (ENTRY + 2, 3, false)] { + crate::debug::debug_spawn_gameobject( + ctx, entry, type_id, 0, 0, MAP, 0.0, 0.0, 0.0, 0, 0, 0, 0, + )?; + crate::go_model::import_go_models_append(ctx, packed_model(entry, 1.0))?; + let guid = (0xF110u64 << 48) | u64::from(entry); + require( + ctx.db.game_go_collider().go_guid().find(guid).is_some() == expected, + "template type gate failed", + )?; + crate::gameobject::despawn_from_relay(ctx, guid)?; + ctx.db.game_go_model().entry().delete(entry); + } + let imported_guid = (0xF110u64 << 48) | u64::from(ENTRY); + crate::gameobject::import_gameobjects( + ctx, + format!("{imported_guid},{ENTRY},{MAP},0,0,0,0,0,0,0,0,0"), + )?; + rays(ctx, 0, Some(2.0))?; + crate::gameobject::despawn_from_relay(ctx, imported_guid)?; + ctx.db.game_go_model().entry().delete(ENTRY); + Ok(()) +} diff --git a/module/src/go_model.rs b/module/src/go_model.rs index df38b19a4..3e309159d 100644 --- a/module/src/go_model.rs +++ b/module/src/go_model.rs @@ -1,13 +1,5 @@ -//! Module-private store for DOOR/BUTTON collision meshes. `importer --go-models` resolves each -//! DOOR/BUTTON `gameobject_template.display_id` to an M2 bounding mesh, pre-applies the MDDF axis -//! shuffle (`importer/src/go_model.rs::shuffle`), and packs the result into local-space triangles -//! via `lyracore_shared::vmap`'s per-triangle codec. This table only STORES that local-space -//! geometry — the state-gated ray merge that CONSUMES it (per-spawn world transform, -//! liveness/open-state gating against `game_gameobject`) is the `game_go_collider` registry. -//! -//! PRIVATE — no gateway binding needed (`docs/danger-zones.md` §1: "a table binding is only -//! needed if the gateway subscribes to or reads that table" — nothing outside this module does -//! yet; an operator inspects it with `spacetime sql`). +//! Imported DOOR and BUTTON meshes in model-local coordinates. +//! Registration derives conservative world bounds; rays read current spawn state and transform. use spacetimedb::{reducer, table, ReducerContext, Table}; @@ -17,8 +9,7 @@ pub struct GoModel { pub entry: u32, /// `gameobject_template.size` (render/collision scale), carried verbatim from the dump. pub scale: f32, - /// Local-space bounding-sphere radius of the shuffled mesh — the cheap segment-reject the - /// ray merge needs before decoding and transforming the full triangle blob. + /// Importer's AABB-centered radius. Registration derives an origin bound from the vertices. pub radius: f32, /// `lyracore_shared::vmap`-codec local-space triangles. The class tag the codec carries is /// unused here — a door's dynamic-ray participation is decided per `game_gameobject` row @@ -35,9 +26,16 @@ fn hex_decode(s: &str) -> Result, String> { if !s.len().is_multiple_of(2) { return Err("odd-length hex blob".to_string()); } - (0..s.len() / 2) - .map(|i| { - u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).map_err(|_| format!("bad hex at {i}")) + s.as_bytes() + .chunks_exact(2) + .enumerate() + .map(|(i, pair)| { + let digit = |byte: u8| { + (byte as char) + .to_digit(16) + .ok_or_else(|| format!("bad hex at {i}")) + }; + Ok((digit(pair[0])? * 16 + digit(pair[1])?) as u8) }) .collect() } @@ -58,8 +56,22 @@ fn parse_row(row: &str) -> Result<(u32, f32, f32, Vec), String> { .parse::() .map_err(|_| format!("bad radius: {}", f[2]))?; let blob = hex_decode(f[3])?; - lyracore_shared::vmap::decode(&blob) + let tris = lyracore_shared::vmap::decode(&blob) .map_err(|err| format!("invalid go_model blob: {err:?}"))?; + if !scale.is_finite() + || scale <= 0.0 + || !radius.is_finite() + || radius < 0.0 + || tris + .iter() + .flat_map(|t| t.verts) + .flatten() + .any(|v| !v.is_finite()) + { + return Err( + "go_model needs finite geometry, a positive scale and a nonnegative radius".to_string(), + ); + } Ok((entry, scale, radius, blob)) } @@ -94,7 +106,7 @@ pub fn import_go_models(ctx: &ReducerContext, packed: String) -> Result<(), Stri if load_go_model_batch(ctx, &packed)? == 0 { return Err("go_model import payload was empty".to_string()); } - Ok(()) + crate::go_collider::rebuild_go_colliders(ctx) } /// Append a go-model batch WITHOUT the reset — a full catalogue can span many `spacetime call` @@ -103,7 +115,7 @@ pub fn import_go_models(ctx: &ReducerContext, packed: String) -> Result<(), Stri pub fn import_go_models_append(ctx: &ReducerContext, packed: String) -> Result<(), String> { crate::helpers::require_operator(ctx)?; load_go_model_batch(ctx, &packed)?; - Ok(()) + crate::go_collider::rebuild_go_colliders(ctx) } #[cfg(test)] @@ -134,6 +146,22 @@ mod tests { assert!(parse_row(row).is_err()); } + #[test] + fn malformed_transform_and_non_ascii_hex_are_refused() { + let blob = lyracore_shared::vmap::encode(&[]); + let hex: String = blob.iter().map(|b| format!("{b:02x}")).collect(); + for (scale, radius) in [ + ("0", "1"), + ("-1", "1"), + ("NaN", "1"), + ("1", "NaN"), + ("1", "-1"), + ] { + assert!(parse_row(&format!("1,{scale},{radius},{hex}")).is_err()); + } + assert!(parse_row("1,1,1,€a").is_err()); + } + // ------------------------------------------------------------------------------------- // Reducer idempotency — a pure in-memory stand-in for the entry-keyed clear+load / // replace-on-append semantics `load_go_model_batch` implements against a real table, diff --git a/module/src/instance.rs b/module/src/instance.rs index 7389a0b65..de8e0bfc1 100644 --- a/module/src/instance.rs +++ b/module/src/instance.rs @@ -725,7 +725,7 @@ pub(crate) fn create_instance_with_id( .collect(); let mut copied = 0u32; for src in &sources { - gos.insert(crate::gameobject::GameObject { + let copy = gos.insert(crate::gameobject::GameObject { guid: (0xF110u64 << 48) | GO_COPY_BAND | next_go_seq, template_entry: src.template_entry, map_id: src.map_id, @@ -747,6 +747,7 @@ pub(crate) fn create_instance_with_id( rotation_2: src.rotation_2, rotation_3: src.rotation_3, }); + crate::go_collider::register(ctx, ©); next_go_seq += 1; copied += 1; } @@ -1000,6 +1001,7 @@ pub(crate) fn teardown_instance_inner(ctx: &ReducerContext, instance_id: u64, de .collect(); for guid in &copies { crate::loot::reap_corpse_loot_family(ctx, *guid); // a GO is not a creature — the loot family only + crate::go_collider::remove(ctx, *guid); gos.guid().delete(guid); } diff --git a/module/src/lib.rs b/module/src/lib.rs index 8ffacbc26..d558777a4 100644 --- a/module/src/lib.rs +++ b/module/src/lib.rs @@ -278,6 +278,9 @@ mod gc; mod gm; // Module-private DOOR/BUTTON collision-mesh store: the import reducers `--go-models` calls. // Consumption (the state-gated ray merge) is the `game_go_collider` registry. +mod go_collider; +#[cfg(feature = "debug_reducers")] +mod go_collider_fixture; mod go_model; // Graveyard resolution (work-item 209/226): the death-release subsystem `world::do_repop` calls to // pick where a ghost teleports. Extracted from `world.rs`. diff --git a/module/src/nav.rs b/module/src/nav.rs index 1b1791178..21ef9f217 100644 --- a/module/src/nav.rs +++ b/module/src/nav.rs @@ -233,23 +233,18 @@ fn fetcher(ctx: &ReducerContext, map_id: u32) -> impl FnMut(u16, u16) -> Option< } } -/// Line of sight for gameplay checks (aggro sensing, hostile casts, engage, swing gate, caster -/// hold-range) (decision §10, benchmark PASS): when exact vmap data is -/// consuming (`vmap::vmap_enabled`), this defers to the exact WMO-only ray -/// (`vmap::los_ray`) instead of the coarse grid raymarch — a hostile behind a real wall is -/// "unseen" at the true wall plane rather than the nearest whole nav cell. Falls back to the grid -/// (243's `nav_enabled` gate) when vmap is off, which itself falls back to `true` ("walls don't -/// exist") when nav is off too — the same three-tier degrade `blink_forward` uses. NOTE: this -/// degrade is per-FLAG, not per-cell — `vmap_enabled` on with zero `game_vmap_chunk` rows for a -/// map (the standard `import-world.sh` pipeline never populates it) reads as "every ray on that -/// map is clear", not "fall back to the grid". `vmap_enabled` therefore defaults OFF -/// (`config.rs`) and is a per-map operator opt-in once `importer --vmap` has actually populated -/// the map's chunks — never flip the default without a wired import step. -pub fn has_los(ctx: &ReducerContext, map_id: u32, a: (f32, f32, f32), b: (f32, f32, f32)) -> bool { - if crate::vmap::vmap_enabled(ctx, map_id) { - return crate::vmap::los_ray(ctx, map_id, [a.0, a.1, a.2], [b.0, b.1, b.2]).is_none(); +/// Closed doors apply even where static geometry falls back to the coarse navigation grid. +pub fn has_los( + ctx: &ReducerContext, + map_id: u32, + instance_id: u64, + a: (f32, f32, f32), + b: (f32, f32, f32), +) -> bool { + if crate::vmap::los_ray(ctx, map_id, instance_id, [a.0, a.1, a.2], [b.0, b.1, b.2]).is_some() { + return false; } - if !nav_enabled(ctx) { + if crate::vmap::vmap_enabled(ctx, map_id) || !nav_enabled(ctx) { return true; } lyracore_shared::nav::has_los(&mut fetcher(ctx, map_id), a, b) @@ -269,9 +264,11 @@ const LEG_MAX_EXPANSIONS: u32 = 4096; /// (`debug/instance.rs`) is the companion harness for THIS path — run it against the same box /// before flipping `vmap_enabled` on a populated map, and compare its wall time to /// `debug_bench_los(exact=true)`'s on that box. +#[allow(clippy::too_many_arguments)] // A movement step carries its partition, endpoints and distances. pub fn nav_step( ctx: &ReducerContext, map_id: u32, + instance_id: u64, cur: (f32, f32), dest: (f32, f32), max_step: f32, @@ -300,7 +297,7 @@ pub fn nav_step( None => crate::creatures::chase_step(cur.0, cur.1, dest.0, dest.1, max_step, stop_dist), } }; - step_gate(ctx, map_id, cur, stepped, z) + step_gate(ctx, map_id, instance_id, cur, stepped, z) } /// The margin `nav_step`'s step gate stops short of a ray hit by — same value @@ -313,6 +310,7 @@ const GATE_CLEARANCE_YD: f32 = 1.0; fn step_gate( ctx: &ReducerContext, map_id: u32, + instance_id: u64, cur: (f32, f32), stepped: (f32, f32), z: f32, @@ -320,18 +318,20 @@ fn step_gate( if stepped == cur { return stepped; } - let hit = if crate::vmap::vmap_enabled(ctx, map_id) { - crate::vmap::collision_ray(ctx, map_id, [cur.0, cur.1, z], [stepped.0, stepped.1, z]) - .map(|h| (h[0], h[1])) - } else if nav_enabled(ctx) { + let a = [cur.0, cur.1, z]; + let b = [stepped.0, stepped.1, z]; + let exact = crate::vmap::collision_ray(ctx, map_id, instance_id, a, b); + let grid = if !crate::vmap::vmap_enabled(ctx, map_id) && nav_enabled(ctx) { lyracore_shared::nav::step_hit( &mut fetcher(ctx, map_id), - (cur.0, cur.1, z), - (stepped.0, stepped.1, z), + (a[0], a[1], a[2]), + (b[0], b[1], b[2]), ) + .map(|p| [p.0, p.1, z]) } else { None }; + let hit = crate::vmap::nearest_hit(a, exact, grid).map(|p| (p[0], p[1])); match hit { Some((hx, hy)) => { let (dx, dy) = (hx - cur.0, hy - cur.1); diff --git a/module/src/package_import.rs b/module/src/package_import.rs index e0332139a..f0424fc48 100644 --- a/module/src/package_import.rs +++ b/module/src/package_import.rs @@ -388,6 +388,10 @@ fn apply_claims( for row in &plan.rows { family.write_row(ctx, row)?; } + if family == ClaimFamily::Gameobjects { + // Operator apply and replay finish every spawn and template edit before reconciling. + crate::go_collider::rebuild_go_colliders(ctx)?; + } Ok(plan.packages) } diff --git a/module/src/spell/cast/targeting.rs b/module/src/spell/cast/targeting.rs index 05cea0d8b..c99e76661 100644 --- a/module/src/spell/cast/targeting.rs +++ b/module/src/spell/cast/targeting.rs @@ -1685,69 +1685,40 @@ fn blink_dest(x: f32, y: f32, orientation: f32, yd: f32) -> (f32, f32) { /// grid-clamp's coarser guarantee). const BLINK_CLEARANCE_YD: f32 = 1.0; -/// Blink: teleport the caster `dist_yd` FORWARD along its facing (Mage Blink). Self-cast — no target. -/// Reuses the teleport core exactly like Charge, but toward a fixed forward point rather than a unit. -/// `dist_yd` is the effect's DBC radius (data-driven, 20yd for Blink) — `resolve_cast_at` already -/// rejected the cast if it was 0, so no fallback here (a silent default would hide mis-seeded data). -/// -/// Collision: when exact vmap data is consuming -/// (`vmap::vmap_enabled`), clamp on the COLLISION ray's first-hit point (WMO + M2 doodads — -/// `vmap::collision_ray`, not the WMO-only LoS ray `has_los` uses) minus `BLINK_CLEARANCE_YD`, so -/// Blink lands right at the obstacle's true plane instead of the nearest whole nav cell before it -/// — carts and abbey columns stop Blink exactly at the collision surface. A wall inside the -/// clearance margin (point-blank) → stay put rather than teleport into geometry. Falls back to the -/// pre-523 grid step-back loop (`nav::has_los`, 2yd steps) when vmap is off — the same rollback -/// posture `nav::has_los` itself keeps. +/// Blink forward, stopping one yard before static or instance door geometry. +/// Maps without an active static generation also retain the coarse grid step-back check. +/// The effect radius is positive by the cast Gate; the teleport keeps the caster's instance. pub(crate) fn blink_forward(ctx: &ReducerContext, caster_guid: u64, dist_yd: f32) { - let entities = ctx.db.game_world_entity(); - let Some(caster) = entities.guid().find(caster_guid) else { + let Some(caster) = ctx.db.game_world_entity().guid().find(caster_guid) else { return; }; - if crate::vmap::vmap_enabled(ctx, caster.map_id) { - let (fx, fy) = blink_dest(caster.x, caster.y, caster.orientation, dist_yd); - let a = [caster.x, caster.y, caster.z]; - let b = [fx, fy, caster.z]; - let (nx, ny) = match crate::vmap::collision_ray(ctx, caster.map_id, a, b) { - Some(hit) => { - let (dx, dy) = (hit[0] - caster.x, hit[1] - caster.y); - let hit_dist = (dx * dx + dy * dy).sqrt(); - let land_dist = hit_dist - BLINK_CLEARANCE_YD; - if land_dist <= 0.0 { - return; // wall inside the clearance margin — stay put - } - ( - caster.x + dx / hit_dist * land_dist, - caster.y + dy / hit_dist * land_dist, - ) - } - None => (fx, fy), // clear ray (or no vmap data this cell) — the full distance - }; - crate::world::teleport_player( - ctx, - caster_guid, - caster.map_id, - 0, - nx, - ny, - caster.z, - caster.orientation, - ); - return; - } - let mut yd = dist_yd; + let (fx, fy) = blink_dest(caster.x, caster.y, caster.orientation, dist_yd); + let a = [caster.x, caster.y, caster.z]; + let b = [fx, fy, caster.z]; + let mut yd = match crate::vmap::collision_ray(ctx, caster.map_id, caster.instance_id, a, b) { + Some(hit) => { + let dx = hit[0] - caster.x; + let dy = hit[1] - caster.y; + (dx * dx + dy * dy).sqrt() - BLINK_CLEARANCE_YD + } + None => dist_yd, + }; while yd > 0.0 { let (nx, ny) = blink_dest(caster.x, caster.y, caster.orientation, yd); - if crate::nav::has_los( - ctx, - caster.map_id, - (caster.x, caster.y, caster.z), - (nx, ny, caster.z), - ) { + if crate::vmap::vmap_enabled(ctx, caster.map_id) + || crate::nav::has_los( + ctx, + caster.map_id, + caster.instance_id, + (caster.x, caster.y, caster.z), + (nx, ny, caster.z), + ) + { crate::world::teleport_player( ctx, caster_guid, caster.map_id, - 0, + caster.instance_id, nx, ny, caster.z, @@ -1884,7 +1855,7 @@ fn charge_to_target(ctx: &ReducerContext, caster_guid: u64, target_guid: u64) { } return; } - let nz = crate::terrain::snap_z(ctx, caster.map_id, nx, ny, target.z); + let nz = crate::terrain::snap_z(ctx, caster.map_id, caster.instance_id, nx, ny, target.z); let duration_ms = ((dist / lyracore_shared::constants::speeds::CHARGE) * 1000.0) as u32; let now_ms = (ctx.timestamp.to_micros_since_unix_epoch() / 1000) as u32; // Emit the rush leg for the PLAYER guid — the gateway relay (build_monster_move) is type-agnostic, diff --git a/module/src/terrain.rs b/module/src/terrain.rs index 56d49f603..8644eee7f 100644 --- a/module/src/terrain.rs +++ b/module/src/terrain.rs @@ -58,9 +58,16 @@ pub fn ground_z(ctx: &ReducerContext, map_id: u32, x: f32, y: f32) -> Option f32 { +pub fn snap_z( + ctx: &ReducerContext, + map_id: u32, + instance_id: u64, + x: f32, + y: f32, + fallback: f32, +) -> f32 { let base = ground_z(ctx, map_id, x, y).unwrap_or(fallback); - match crate::vmap::floor_z(ctx, map_id, x, y, fallback) { + match crate::vmap::floor_z(ctx, map_id, instance_id, x, y, fallback) { Some(floor) => base.max(floor), None => base, } diff --git a/module/src/tripwires.rs b/module/src/tripwires.rs index b9b794475..797fc209c 100644 --- a/module/src/tripwires.rs +++ b/module/src/tripwires.rs @@ -850,6 +850,7 @@ mod partition_discipline_tripwire { ("module/src/debug/instance.rs", 4, "`debug_reducers`-gated test harness; compiled out of production builds entirely; +2 for #526's `debug_assert_floor_snap`, which scans game_creature_spawn/game_world_entity by `entry` for a guid high-water mark (template-keyed, not a spatial query) before minting a scratch spawn guid"), // Importers — realm-wide by definition: they wipe and rebuild every partition at once. ("module/src/creatures/spawn.rs", 4, "`import_creature_spawns` drops every creature entity + spawn row before reloading the world; +1 for `debug_normalize_spawn_timers`, a one-shot operator migration that must visit this database's whole spawn table by definition; +1 for `debug_retire_region_creatures` (#194), which manages the partitioning itself — it scans spawn HOMES to hand a region's population to its owning shard"), + ("module/src/go_collider.rs", 1, "Operator reconciliation rebuilds derived colliders for every hosted partition"), ("module/src/gameobject.rs", 2, "`import_gameobjects` drops every gameobject row before reloading the world; plus one by-`template_entry` lookup (an entry-keyed find, not a spatial query — no index on that column)"), ("module/src/package_import/creatures.rs", 1, "a Package Delta apply clears the whole Package creature range before it writes, so a Package that left the enabled set takes its spawns with it — realm-wide reconciliation, and the band has no index"), ("module/src/package_import/gameobjects.rs", 1, "the same whole-band clear for the Package gameobject range"), diff --git a/module/src/vmap.rs b/module/src/vmap.rs index 84d41e7a9..0261ab3bf 100644 --- a/module/src/vmap.rs +++ b/module/src/vmap.rs @@ -1,13 +1,6 @@ -//! Exact per-cell collision-triangle store + ray queries (part of the full-vmap -//! epic; design record `docs/decisions.md` §10). Builds on the codec/binning -//! (`lyracore_shared::vmap`): this slice adds the module-PRIVATE table the packed per-cell blobs -//! land in, the import reducers that fill it (mirroring `nav::import_nav_chunks`/`_append`), and -//! the module-side wrappers over `lyracore_shared::vmap::cast_ray` — LoS (WMO-class triangles -//! only) and collision (WMO + M2 doodads). -//! -//! Gated on `game_config.vmap_enabled` (default OFF, mirroring `nav_enabled`'s pre-244 posture): -//! off, or a world with no vmap data imported, both ray queries return `None` (clear) — the same -//! missing-chunk-means-unobstructed contract `nav` uses, so nothing regresses on an unimported map. +//! Static collision generations and instance-aware geometry queries. +//! The global Gate controls gameplay rays. Static hits require an active generation; +//! closed DOOR/BUTTON hits use their own instance-scoped registration. use crate::terrain::game_terrain_chunk; use lyracore_shared::terrain::cell_key; @@ -767,17 +760,19 @@ fn active_generation_id(ctx: &ReducerContext, map_id: u32) -> Option { .map(|generation| generation.id) } -/// Exact geometry can consume only when the global kill-switch is on AND this map has a complete -/// active generation. Staging or legacy chunks never make an uncovered map read as clear. +/// Static geometry consumes only with the global Gate and a complete active generation. +/// This decides whether navigation still needs its coarse grid fallback. pub fn vmap_enabled(ctx: &ReducerContext, map_id: u32) -> bool { - let configured = ctx - .db + rays_enabled(ctx) && active_generation_id(ctx, map_id).is_some() +} + +/// Global collision Gate. Dynamic doors do not require a static generation. +pub(crate) fn rays_enabled(ctx: &ReducerContext) -> bool { + ctx.db .game_config() .id() .find(0) - .map(|c| c.vmap_enabled) - .unwrap_or(false); - configured && active_generation_id(ctx, map_id).is_some() + .is_some_and(|c| c.vmap_enabled) } /// Chunk fetch closure for `cast_ray`: one indexed scan + decode per crossed cell, gathering @@ -807,43 +802,72 @@ fn fetcher(ctx: &ReducerContext, map_id: u32) -> impl FnMut(u16, u16) -> Option< } } -/// Exact line-of-sight ray: WMO-class triangles only (doodads/forests never block sight). `None` -/// when vmap is off, unimported, or the segment is clear; `Some(point)` = first-hit world point. -pub fn los_ray(ctx: &ReducerContext, map_id: u32, a: [f32; 3], b: [f32; 3]) -> Option<[f32; 3]> { - if !vmap_enabled(ctx, map_id) { - return None; - } - lyracore_shared::vmap::cast_ray(&mut fetcher(ctx, map_id), a, b, RayFlavor::Los) +/// Exact sight includes WMO geometry and closed doors in the requested instance. +pub fn los_ray( + ctx: &ReducerContext, + map_id: u32, + instance_id: u64, + a: [f32; 3], + b: [f32; 3], +) -> Option<[f32; 3]> { + rays_enabled(ctx) + .then(|| ray(ctx, map_id, instance_id, a, b, RayFlavor::Los)) + .flatten() } -/// Exact collision ray (WMO + M2 doodads) — the first-hit point for movement/reach clamps. +/// Exact collision includes WMO, static doodads and closed doors. pub fn collision_ray( ctx: &ReducerContext, map_id: u32, + instance_id: u64, a: [f32; 3], b: [f32; 3], ) -> Option<[f32; 3]> { - if !vmap_enabled(ctx, map_id) { - return None; + rays_enabled(ctx) + .then(|| ray(ctx, map_id, instance_id, a, b, RayFlavor::Collision)) + .flatten() +} + +pub(crate) fn nearest_hit( + a: [f32; 3], + first: Option<[f32; 3]>, + second: Option<[f32; 3]>, +) -> Option<[f32; 3]> { + let distance = |p: [f32; 3]| p.iter().zip(a).map(|(x, y)| (x - y) * (x - y)).sum::(); + match (first, second) { + (Some(x), Some(y)) => Some(if distance(x) <= distance(y) { x } else { y }), + (x, None) | (None, x) => x, } - lyracore_shared::vmap::cast_ray(&mut fetcher(ctx, map_id), a, b, RayFlavor::Collision) } -/// Operator-only geometry read: consults a verified active generation but deliberately ignores -/// the gameplay kill-switch. Debug probes use this to establish collision evidence before an -/// operator chooses to enable live consumption. +fn ray( + ctx: &ReducerContext, + map_id: u32, + instance_id: u64, + a: [f32; 3], + b: [f32; 3], + flavor: RayFlavor, +) -> Option<[f32; 3]> { + let static_hit = lyracore_shared::vmap::cast_ray(&mut fetcher(ctx, map_id), a, b, flavor); + nearest_hit( + a, + static_hit, + crate::go_collider::ray(ctx, map_id, instance_id, a, b), + ) +} + +/// Operator geometry probe, independent of the gameplay Gate. pub fn probe_rays( ctx: &ReducerContext, map_id: u32, + instance_id: u64, a: [f32; 3], b: [f32; 3], ) -> (Option<[f32; 3]>, Option<[f32; 3]>) { - let mut los_fetcher = fetcher(ctx, map_id); - let los = lyracore_shared::vmap::cast_ray(&mut los_fetcher, a, b, RayFlavor::Los); - let mut collision_fetcher = fetcher(ctx, map_id); - let collision = - lyracore_shared::vmap::cast_ray(&mut collision_fetcher, a, b, RayFlavor::Collision); - (los, collision) + ( + ray(ctx, map_id, instance_id, a, b, RayFlavor::Los), + ray(ctx, map_id, instance_id, a, b, RayFlavor::Collision), + ) } // =========================================================================================== @@ -860,33 +884,34 @@ const FLOOR_PROBE_UP_YD: f32 = 2.0; /// a multi-deck WMO interior (Deadmines) from a probe standing on an upper deck. const FLOOR_PROBE_DOWN_YD: f32 = 200.0; -/// Topmost model-floor (WMO + M2 collision-class) triangle at or below `probe_z`, at (x, y) on -/// `map_id`. A single downward `collision_ray` cast from `probe_z + FLOOR_PROBE_UP_YD` to -/// `probe_z - FLOOR_PROBE_DOWN_YD`: `cast_ray` returns the NEAREST hit along the segment, which -/// for a downward segment is exactly the highest surface at or below the start — the "topmost -/// floor" this function promises. `None` when vmap is off, unimported, or no floor triangle lies -/// in the search range (mirrors every other vmap query's missing-chunk-means-nothing-found -/// contract — callers keep their current Z). -pub fn floor_z(ctx: &ReducerContext, map_id: u32, x: f32, y: f32, probe_z: f32) -> Option { +/// Highest static or closed-door surface in the vertical probe segment in this partition. +/// The segment starts slightly above `probe_z` so a point already on the surface can find it. +/// Returns `None` when the gameplay Gate is off or no collision triangle lies in that segment. +pub fn floor_z( + ctx: &ReducerContext, + map_id: u32, + instance_id: u64, + x: f32, + y: f32, + probe_z: f32, +) -> Option { let top = [x, y, probe_z + FLOOR_PROBE_UP_YD]; let bottom = [x, y, probe_z - FLOOR_PROBE_DOWN_YD]; - collision_ray(ctx, map_id, top, bottom).map(|hit| hit[2]) + collision_ray(ctx, map_id, instance_id, top, bottom).map(|hit| hit[2]) } -/// Read-only model-floor probe for a verified active generation, independent of the gameplay -/// gate. See [`probe_rays`] for why diagnostics must not flip that gate. +/// Static and closed-door floor probe, independent of the gameplay Gate. pub fn probe_floor_z( ctx: &ReducerContext, map_id: u32, + instance_id: u64, x: f32, y: f32, probe_z: f32, ) -> Option { let top = [x, y, probe_z + FLOOR_PROBE_UP_YD]; let bottom = [x, y, probe_z - FLOOR_PROBE_DOWN_YD]; - let mut fetcher = fetcher(ctx, map_id); - lyracore_shared::vmap::cast_ray(&mut fetcher, top, bottom, RayFlavor::Collision) - .map(|hit| hit[2]) + ray(ctx, map_id, instance_id, top, bottom, RayFlavor::Collision).map(|hit| hit[2]) } // =========================================================================================== diff --git a/module/tests/go_collision.rs b/module/tests/go_collision.rs new file mode 100644 index 000000000..20474adf0 --- /dev/null +++ b/module/tests/go_collision.rs @@ -0,0 +1,21 @@ +//! Real Module registration, instance lifecycle and gameplay ray queries on a private fixture. +mod support; + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn closed_doors_block_only_their_own_instance() { + let mut fixture = support::Standalone::start("go-collision"); + fixture.publish_module(); + fixture.assert_call("claim_operator", &[]); + fixture.assert_call("debug_spawn_player_entity", &["1"]); + fixture.assert_call("debug_assert_unreachable_goal_stops_at_wall", &["1"]); + fixture.assert_call("debug_assert_go_collision", &[]); + fixture.assert_call("debug_vmap_ray", &["36", "0", "0.5", "0", "4", "0.5", "0"]); + fixture.assert_call( + "debug_vmap_ray_instance", + &["36", "0", "0.5", "0", "4", "0.5", "0", "0"], + ); + assert!(fixture + .query_rows("SELECT * FROM game_go_collider") + .is_empty()); +} From fd7b8906f515fca9c16282a40f35c3ea6ea93206 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 06:49:15 +0000 Subject: [PATCH 2/3] fix(vmap): track pooled door activation and removal --- docs/vmap-rollout.md | 2 +- module/src/gameobject.rs | 5 +- module/src/go_collider_fixture.rs | 82 +++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 2 deletions(-) diff --git a/docs/vmap-rollout.md b/docs/vmap-rollout.md index 4d53e1c4d..89b01a634 100644 --- a/docs/vmap-rollout.md +++ b/docs/vmap-rollout.md @@ -137,7 +137,7 @@ They use `game_go_collider` rows indexed by map and instance. Map 36 needs no st for these rays. Static geometry retains the active-generation requirement; the navigation grid still supplies the fallback on maps without one. Area and indoor queries stay static. -GameObject imports, model imports, debug spawns and instance creation register eligible doors. +GameObject imports, model imports, pool activation, debug spawns and instance creation register eligible doors. Gameobjects Package Delta apply and replay reconcile after all spawn and template changes finish. Model imports rebuild the registry so either import order works. `rebuild_go_colliders` is an Operator reconciliation for existing rows after a publish. Open doors keep their registration; diff --git a/module/src/gameobject.rs b/module/src/gameobject.rs index 171cd2872..3cb9e7264 100644 --- a/module/src/gameobject.rs +++ b/module/src/gameobject.rs @@ -410,6 +410,7 @@ pub(crate) fn reroll_pool(ctx: &ReducerContext, pool_id: u32, gathered_guid: u64 let gos = ctx.db.game_gameobject(); let members = ctx.db.game_gameobject_pool_member(); // 1. DEACTIVATE the gathered point (the −1). The just-freed point is itself eligible again below. + crate::go_collider::remove(ctx, gathered_guid); gos.guid().delete(gathered_guid); // 2. Candidate set = pool members with weight > 0 that are currently INACTIVE (no live row at their // derived guid). The just-deleted gathered point is now inactive → eligible to be re-picked (a @@ -447,7 +448,7 @@ pub(crate) fn reroll_pool(ctx: &ReducerContext, pool_id: u32, gathered_guid: u64 /// `arm_pool` (initial fill) and `reroll_pool` (rotation). Idempotent-by-guid: the caller guarantees /// the point is currently inactive (no row at its guid), so this is a plain insert. fn activate_point(ctx: &ReducerContext, m: &GameObjectPoolMember) { - ctx.db.game_gameobject().insert(GameObject { + let go = ctx.db.game_gameobject().insert(GameObject { guid: pool_point_guid(m.point_id), template_entry: m.template_entry, map_id: m.map_id, @@ -469,6 +470,7 @@ fn activate_point(ctx: &ReducerContext, m: &GameObjectPoolMember) { rotation_2: 0.0, rotation_3: 0.0, }); + crate::go_collider::register(ctx, &go); } /// Every map id this database currently holds REAL imported spatial content for, read off @@ -538,6 +540,7 @@ pub(crate) fn arm_pool(ctx: &ReducerContext, pool_id: u32) { .unwrap_or(0); // Clear any prior live rows at THIS pool's points (idempotent arming — a re-setup re-fills cleanly). for m in members.by_pool().filter(&pool_id) { + crate::go_collider::remove(ctx, pool_point_guid(m.point_id)); gos.guid().delete(pool_point_guid(m.point_id)); } // Activate max_active weighted-distinct points: re-query the inactive eligible set each iteration (a diff --git a/module/src/go_collider_fixture.rs b/module/src/go_collider_fixture.rs index 417518f68..03f2ed3b3 100644 --- a/module/src/go_collider_fixture.rs +++ b/module/src/go_collider_fixture.rs @@ -159,6 +159,87 @@ fn packed_model(entry: u32, scale: f32) -> String { format!("{entry},{scale},0.01,{hex}") } +fn pool_lifecycle(ctx: &ReducerContext) -> Result<(), String> { + use crate::gameobject::{ + game_gameobject_pool, game_gameobject_pool_member, GameObjectPool, GameObjectPoolMember, + }; + let pools = ctx.db.game_gameobject_pool(); + let members = ctx.db.game_gameobject_pool_member(); + pools.insert(GameObjectPool { + pool_id: ENTRY, + max_active: 1, + in_place: false, + }); + let mut member = members.insert(GameObjectPoolMember { + point_id: 0, + pool_id: ENTRY, + template_entry: ENTRY, + map_id: MAP, + x: -2.0, + y: 0.0, + z: 0.0, + orientation: 0.0, + weight: 1, + }); + let guid = crate::gameobject::pool_point_guid(member.point_id); + for (type_id, expected) in [(25, None), (0, Some(-1.0))] { + package_claim( + ctx, + &format!( + r#"{{"table":"game_gameobject_template","key":{{"entry":{ENTRY}}},"operation":"update","fields":{{"type_id":{{"type":"u8","value":{type_id}}}}}}}"# + ), + )?; + crate::gameobject::arm_all_pools(ctx)?; + rays(ctx, 0, expected)?; + } + + member.x = 40.0; + member = members.point_id().update(member); + crate::gameobject::arm_all_pools(ctx)?; + rays(ctx, 0, Some(1.0))?; + for hit in [ + crate::vmap::los_ray(ctx, MAP, 0, [36.0, 0.5, 0.0], [44.0, 0.5, 0.0]), + crate::vmap::collision_ray(ctx, MAP, 0, [36.0, 0.5, 0.0], [44.0, 0.5, 0.0]), + ] { + require( + hit.is_some_and(|p| (p[0] - 41.0).abs() < 0.001), + "pool rearm left the collider at its old position", + )?; + } + member.x = -2.0; + let point_id = member.point_id; + member = members.point_id().update(member); + crate::gameobject::reroll_pool(ctx, ENTRY, guid); + rays(ctx, 0, Some(-1.0))?; + + member.weight = 0; + member = members.point_id().update(member); + crate::gameobject::reroll_pool(ctx, ENTRY, guid); + require( + ctx.db.game_go_collider().go_guid().find(guid).is_none(), + "empty pool reroll retained a collider", + )?; + rays(ctx, 0, Some(1.0))?; + member.weight = 1; + members.point_id().update(member); + crate::gameobject::arm_all_pools(ctx)?; + rays(ctx, 0, Some(-1.0))?; + pools.pool_id().update(GameObjectPool { + pool_id: ENTRY, + max_active: 0, + in_place: false, + }); + crate::gameobject::arm_all_pools(ctx)?; + require( + ctx.db.game_go_collider().go_guid().find(guid).is_none(), + "cleared pool retained a collider", + )?; + rays(ctx, 0, Some(1.0))?; + members.point_id().delete(point_id); + pools.pool_id().delete(ENTRY); + Ok(()) +} + #[reducer] pub fn debug_assert_go_collision(ctx: &ReducerContext) -> Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -212,6 +293,7 @@ pub fn debug_assert_go_collision(ctx: &ReducerContext) -> Result<(), String> { crate::gameobject::despawn_from_relay(ctx, nearer_guid)?; package_lifecycle(ctx)?; + pool_lifecycle(ctx)?; let first = crate::instance::create_instance(ctx, MAP, 0)?; let sibling = crate::instance::create_instance(ctx, MAP, 0)?; rays(ctx, first, Some(1.0))?; From 37d1a1e2eec8f0a20fcb5126f60719b6da1033a4 Mon Sep 17 00:00:00 2001 From: T3 Code Date: Tue, 8 Sep 2026 07:53:18 +0000 Subject: [PATCH 3/3] fix(test): roll back the door collision fixture --- module/src/go_collider_fixture.rs | 4 +- module/tests/go_collision.rs | 63 ++++++++++++++++++++++++++++++- 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/module/src/go_collider_fixture.rs b/module/src/go_collider_fixture.rs index 03f2ed3b3..aae8797b0 100644 --- a/module/src/go_collider_fixture.rs +++ b/module/src/go_collider_fixture.rs @@ -1,4 +1,4 @@ -//! Synthetic durable verification. Run only on an isolated fixture Shard. +//! Synthetic durable verification. Even success aborts the transaction to preserve existing rows. use crate::game_world_entity; use crate::gameobject::game_gameobject; @@ -453,5 +453,5 @@ pub fn debug_assert_go_collision(ctx: &ReducerContext) -> Result<(), String> { rays(ctx, 0, Some(2.0))?; crate::gameobject::despawn_from_relay(ctx, imported_guid)?; ctx.db.game_go_model().entry().delete(ENTRY); - Ok(()) + Err("GO_COLLISION_FIXTURE_PASSED_ROLLED_BACK".to_string()) } diff --git a/module/tests/go_collision.rs b/module/tests/go_collision.rs index 20474adf0..f90b7837a 100644 --- a/module/tests/go_collision.rs +++ b/module/tests/go_collision.rs @@ -9,7 +9,68 @@ fn closed_doors_block_only_their_own_instance() { fixture.assert_call("claim_operator", &[]); fixture.assert_call("debug_spawn_player_entity", &["1"]); fixture.assert_call("debug_assert_unreachable_goal_stops_at_wall", &["1"]); - fixture.assert_call("debug_assert_go_collision", &[]); + + let model_hex = lyracore_shared::vmap::encode(&[]) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + fixture.assert_call( + "import_go_models_append", + &[&serde_json::to_string(&format!("709113,1,0,{model_hex}")).unwrap()], + ); + let templates = fixture.query_rows("SELECT entry, type_id FROM game_gameobject_template"); + let template = templates.first().expect("a seeded GameObject template"); + let package = serde_json::json!({ + "version": 1, + "package": "fixture.preserved", + "source_hash": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "claims": [{ + "table": "game_gameobject_template", + "key": {"entry": template["entry"].parse::().unwrap()}, + "operation": "update", + "fields": {"type_id": {"type": "u8", "value": template["type_id"].parse::().unwrap()}} + }] + }); + fixture.assert_call( + "apply_package_deltas", + &[ + "\"gameobjects\"", + &serde_json::to_string(&package.to_string()).unwrap(), + ], + ); + let preserved = [ + "SELECT * FROM game_gameobject", + "SELECT * FROM game_gameobject_template", + "SELECT * FROM game_go_model", + "SELECT * FROM game_world_entity WHERE guid = 1", + "SELECT * FROM game_character WHERE guid = 1", + "SELECT * FROM game_config", + "SELECT * FROM game_package_import", + ] + .map(|query| { + let mut rows = fixture.query_rows(query); + assert!(!rows.is_empty(), "seeded rows required for {query}"); + rows.sort(); + (query, rows) + }); + + let result = fixture.call("debug_assert_go_collision", &[]); + let output = format!( + "{}{}", + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&result.stderr) + ); + assert!(!result.status.success(), "fixture must roll back"); + assert!( + output.contains("GO_COLLISION_FIXTURE_PASSED_ROLLED_BACK"), + "fixture did not reach its success marker: {output}" + ); + for (query, expected) in preserved { + let mut actual = fixture.query_rows(query); + actual.sort(); + assert_eq!(actual, expected, "fixture changed rows in {query}"); + } + fixture.assert_call("debug_vmap_ray", &["36", "0", "0.5", "0", "4", "0.5", "0"]); fixture.assert_call( "debug_vmap_ray_instance",