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..89b01a634 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, 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; +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..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 @@ -946,6 +949,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 +1102,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 +1150,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..aae8797b0 --- /dev/null +++ b/module/src/go_collider_fixture.rs @@ -0,0 +1,457 @@ +//! Synthetic durable verification. Even success aborts the transaction to preserve existing rows. + +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}") +} + +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)?; + 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)?; + 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))?; + 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); + Err("GO_COLLISION_FIXTURE_PASSED_ROLLED_BACK".to_string()) +} 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..f90b7837a --- /dev/null +++ b/module/tests/go_collision.rs @@ -0,0 +1,82 @@ +//! 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"]); + + 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", + &["36", "0", "0.5", "0", "4", "0.5", "0", "0"], + ); + assert!(fixture + .query_rows("SELECT * FROM game_go_collider") + .is_empty()); +}