From 8b4095a410ecfad0e011d565b4a838d0dcec52ab Mon Sep 17 00:00:00 2001 From: Mathias Hartveit Date: Mon, 5 Oct 2026 18:52:15 +0200 Subject: [PATCH] fix(creatures): start a renewed path where the client draws the mover emit_creature_path started the new leg at the mover's stored position. The stored row can lag the leg by up to 4 yd for an out-of-combat creature and by one firing for a Character, so a renewal between two advance firings sent the client back along the old leg. The path now starts at the point stop_where_rendered would stop the mover at, including the obstruction check, and takes a spline id newer than the leg it replaces. Both share place_where_rendered. debug_creature_path drives emit_creature_path on a test shard, and creature_path_renewal renews a path mid-leg and checks the new start. Closes #608 Co-Authored-By: Claude Opus 5.5 (1M context) --- CONTEXT.md | 4 +- docs/package-api.md | 5 + .../bindings/debug_creature_path_reducer.rs | 91 +++++++++++++++ gateway/src/stdb/bindings/mod.rs | 23 ++++ module/src/creatures/tick/mod.rs | 30 +++-- module/src/debug/mod.rs | 17 +++ module/tests/creature_path_renewal.rs | 105 ++++++++++++++++++ 7 files changed, 263 insertions(+), 12 deletions(-) create mode 100644 gateway/src/stdb/bindings/debug_creature_path_reducer.rs create mode 100644 module/tests/creature_path_renewal.rs diff --git a/CONTEXT.md b/CONTEXT.md index 799ddb7f0..fde3e17a3 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -559,8 +559,8 @@ evidence. Its endpoint is proposed movement, not proof of advancement or arrival A bounded sequence of waypoints retained for one movement. The Module advances along the same geometry and duration that the Gateway sends to the client. Crossing a waypoint does not require another decision. A changed destination or a failed movement Gate can replace or stop the path. A -stop holds the point the client renders at that moment, even between two advance firings, unless -the advance would halt the path short of it. +stop holds, and a replacement path starts from, the point the client renders at that moment, even +between two advance firings, unless the advance would halt the path short of it. **Cast Handle**: The scheduled identity, spell, target, and current due time of one timed cast. Repeated bot requests diff --git a/docs/package-api.md b/docs/package-api.md index 2bdee0af5..fb8cf9fdc 100644 --- a/docs/package-api.md +++ b/docs/package-api.md @@ -302,6 +302,11 @@ there. The caller writes the row. A mover's stored position can lag its leg, so position moves the client back. A blocked Route Path segment or changed navigation inputs halt the stop where a leg advance would halt, so it never lands past an obstruction. +`creatures::tick::emit_creature_path(ctx, mover, points, run)` sends the mover along a checked Route +Path and writes the row. A mover on a leg starts the path from the point that +`stop_where_rendered` would stop it at, so a renewal between two advance firings does not move the +client back. + `actor::area_trigger_route(ctx, trigger_id)` reads one exact imported AreaTrigger source volume and target map. It exposes the source center and containment rule for Candidate movement while keeping the landing coordinates private. `actor::enter_sessionless_areatrigger` rechecks the current body, diff --git a/gateway/src/stdb/bindings/debug_creature_path_reducer.rs b/gateway/src/stdb/bindings/debug_creature_path_reducer.rs new file mode 100644 index 000000000..0b58949eb --- /dev/null +++ b/gateway/src/stdb/bindings/debug_creature_path_reducer.rs @@ -0,0 +1,91 @@ +// 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 DebugCreaturePathArgs { + pub guid: u64, + pub x: f32, + pub y: f32, + pub z: f32, + pub run: bool, +} + +impl From for super::Reducer { + fn from(args: DebugCreaturePathArgs) -> Self { + Self::DebugCreaturePath { + guid: args.guid, + x: args.x, + y: args.y, + z: args.z, + run: args.run, + } + } +} + +impl __sdk::InModule for DebugCreaturePathArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `debug_creature_path`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait debug_creature_path { + /// Request that the remote module invoke the reducer `debug_creature_path` 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_creature_path:debug_creature_path_then`] to run a callback after the reducer completes. + fn debug_creature_path( + &self, + guid: u64, + x: f32, + y: f32, + z: f32, + run: bool, + ) -> __sdk::Result<()> { + self.debug_creature_path_then(guid, x, y, z, run, |_, _| {}) + } + + /// Request that the remote module invoke the reducer `debug_creature_path` 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_creature_path_then( + &self, + guid: u64, + x: f32, + y: f32, + z: f32, + run: bool, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl debug_creature_path for super::RemoteReducers { + fn debug_creature_path_then( + &self, + guid: u64, + x: f32, + y: f32, + z: f32, + run: bool, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp + .invoke_reducer_with_callback(DebugCreaturePathArgs { guid, x, y, z, run }, callback) + } +} diff --git a/gateway/src/stdb/bindings/mod.rs b/gateway/src/stdb/bindings/mod.rs index 80010698a..1ba2aa7ee 100644 --- a/gateway/src/stdb/bindings/mod.rs +++ b/gateway/src/stdb/bindings/mod.rs @@ -169,6 +169,7 @@ pub mod debug_clear_creatures_reducer; pub mod debug_compute_spell_reducer; pub mod debug_compute_swing_reducer; pub mod debug_create_fixture_instance_reducer; +pub mod debug_creature_path_reducer; pub mod debug_delete_character_reducer; pub mod debug_deliver_mail_fixture_reducer; pub mod debug_disarm_instance_tick_reducer; @@ -1334,6 +1335,7 @@ pub use debug_clear_creatures_reducer::debug_clear_creatures; pub use debug_compute_spell_reducer::debug_compute_spell; pub use debug_compute_swing_reducer::debug_compute_swing; pub use debug_create_fixture_instance_reducer::debug_create_fixture_instance; +pub use debug_creature_path_reducer::debug_creature_path; pub use debug_delete_character_reducer::debug_delete_character; pub use debug_deliver_mail_fixture_reducer::debug_deliver_mail_fixture; pub use debug_disarm_instance_tick_reducer::debug_disarm_instance_tick; @@ -2632,6 +2634,13 @@ pub enum Reducer { DebugCreateFixtureInstance { character_guid: u64, }, + DebugCreaturePath { + guid: u64, + x: f32, + y: f32, + z: f32, + run: bool, + }, DebugDeleteCharacter { character_guid: u64, }, @@ -4423,6 +4432,7 @@ impl __sdk::Reducer for Reducer { Reducer::DebugComputeSpell { .. } => "debug_compute_spell", Reducer::DebugComputeSwing { .. } => "debug_compute_swing", Reducer::DebugCreateFixtureInstance { .. } => "debug_create_fixture_instance", + Reducer::DebugCreaturePath { .. } => "debug_creature_path", Reducer::DebugDeleteCharacter { .. } => "debug_delete_character", Reducer::DebugDeliverMailFixture { .. } => "debug_deliver_mail_fixture", Reducer::DebugDisarmInstanceTick { .. } => "debug_disarm_instance_tick", @@ -5384,6 +5394,19 @@ Reducer::DebugCheckRestAt{ character_guid, } => __sats::bsatn::to_vec(&debug_create_fixture_instance_reducer::DebugCreateFixtureInstanceArgs { character_guid: character_guid.clone(), +}), + Reducer::DebugCreaturePath{ + guid, + x, + y, + z, + run, +} => __sats::bsatn::to_vec(&debug_creature_path_reducer::DebugCreaturePathArgs { + guid: guid.clone(), + x: x.clone(), + y: y.clone(), + z: z.clone(), + run: run.clone(), }), Reducer::DebugDeleteCharacter{ character_guid, diff --git a/module/src/creatures/tick/mod.rs b/module/src/creatures/tick/mod.rs index 49525044e..cbd458000 100644 --- a/module/src/creatures/tick/mod.rs +++ b/module/src/creatures/tick/mod.rs @@ -647,6 +647,8 @@ pub struct CreaturePathPoint { } /// Publish the already checked path as one movement, shared by server advance and client relay. +/// A mover on a leg starts the path where [`stop_where_rendered`] would stop it, so a renewal +/// between two advance firings does not move the client back. #[cfg_attr(not(has_packages), allow(dead_code))] pub(crate) fn emit_creature_path( ctx: &ReducerContext, @@ -658,6 +660,10 @@ pub(crate) fn emit_creature_path( let Some(&destination) = points.last() else { return; }; + let now_micros = ctx.timestamp.to_micros_since_unix_epoch() as u64; + let now_ms = (now_micros / 1000) as u32; + let spline_id = place_where_rendered(ctx, &mut mover) + .map_or(now_ms, |previous| next_spline_id(now_micros, previous)); let start = (mover.x, mover.y, mover.z); let length = movement_path::length(start, &points); if !length.is_finite() || length <= 0.0 { @@ -665,7 +671,6 @@ pub(crate) fn emit_creature_path( } let speed = if run { speeds::RUN } else { speeds::WALK }; let duration = (length / speed * 1000.0).ceil().max(1.0) as u32; - let now_ms = (ctx.timestamp.to_micros_since_unix_epoch() / 1000) as u32; emit_move_spline( ctx, mover.guid, @@ -673,7 +678,7 @@ pub(crate) fn emit_creature_path( destination, duration, run, - now_ms, + spline_id, mover.map_id, mover.instance_id, (mover.grid_x, mover.grid_y), @@ -758,16 +763,10 @@ pub(crate) fn emit_creature_leg( /// navigation inputs halt the stop where a leg advance would halt, so it never lands past an /// obstruction. The caller writes `mover`. A mover with no leg is unchanged. pub(crate) fn stop_where_rendered(ctx: &ReducerContext, mover: &mut WorldEntity) { - let Some(leg) = ctx.db.game_creature_spline().guid().find(mover.guid) else { + let Some(previous) = place_where_rendered(ctx, mover) else { return; }; - let spline_id = next_spline_id( - ctx.timestamp.to_micros_since_unix_epoch() as u64, - leg.spline_id, - ); - if let Some(stop) = super::cycle::stop_on_stored_leg(ctx, leg) { - place_stopped(mover, stop); - } + let spline_id = next_spline_id(ctx.timestamp.to_micros_since_unix_epoch() as u64, previous); let at = (mover.x, mover.y, mover.z); emit_move_spline( ctx, @@ -783,6 +782,17 @@ pub(crate) fn stop_where_rendered(ctx: &ReducerContext, mover: &mut WorldEntity) ); } +/// Move `mover` to where a stop now would leave it on its leg, and return that leg's spline id. +/// `None` for a mover with no leg, which stays unchanged. The caller writes `mover`. +fn place_where_rendered(ctx: &ReducerContext, mover: &mut WorldEntity) -> Option { + let leg = ctx.db.game_creature_spline().guid().find(mover.guid)?; + let spline_id = leg.spline_id; + if let Some(stop) = super::cycle::stop_on_stored_leg(ctx, leg) { + place_stopped(mover, stop); + } + Some(spline_id) +} + /// The id for a leg that replaces one with id `previous`. The client ignores an id that does not /// exceed the one it replaces, so a second leg in the same millisecond takes `previous + 1`. fn next_spline_id(now_micros: u64, previous: u32) -> u32 { diff --git a/module/src/debug/mod.rs b/module/src/debug/mod.rs index 99120b336..0fbb13f16 100644 --- a/module/src/debug/mod.rs +++ b/module/src/debug/mod.rs @@ -1505,6 +1505,23 @@ pub fn debug_turn_in_quest( crate::actor::turn_in_quest(ctx, character_guid, giver_guid, quest_entry, reward_index) } +/// Start or renew a one-waypoint Route Path for `guid` to (`x`, `y`, `z`) through +/// `creatures::tick::emit_creature_path`, the call a Package runner makes. +#[reducer] +pub fn debug_creature_path( + ctx: &ReducerContext, + guid: u64, + x: f32, + y: f32, + z: f32, + run: bool, +) -> Result<(), String> { + crate::helpers::require_operator(ctx)?; + let mover = crate::helpers::live_entity(ctx, guid)?; + crate::creatures::tick::emit_creature_path(ctx, mover, vec![(x, y, z)], run); + Ok(()) +} + /// Kill creature `target_guid` crediting player `killer_guid` — drives the SHARED killing-blow path /// (`combat::kill_creature` with a killer), so it produces the exact same corpse + loot AND the XP + /// quest-objective credit a real player kill does. The machine-test counterpart to fighting a mob to diff --git a/module/tests/creature_path_renewal.rs b/module/tests/creature_path_renewal.rs new file mode 100644 index 000000000..3f99213d7 --- /dev/null +++ b/module/tests/creature_path_renewal.rs @@ -0,0 +1,105 @@ +//! A Route Path renewed mid-leg starts where the client renders the mover at the renewal, not at +//! the position the last advance firing stored. The renewal lands between firings, which only a +//! real shard shows. + +mod support; + +use std::collections::BTreeMap; +use std::time::Duration; + +use lyracore_shared::spatial::{grid_cell, GRID_CELL_SIZE, MAP_COORD_MAX}; +use support::Standalone; + +/// Long enough to span several firings, short enough to stay inside one 50 yd cell. +const LEG_YD: f32 = 20.0; + +type Row = BTreeMap; + +fn number(row: &Row, column: &str) -> T { + row[column] + .parse() + .unwrap_or_else(|_| panic!("{column} is not a number in {row:?}")) +} + +fn leg(shard: &Standalone, guid: &str) -> Row { + let mut legs = shard.query_rows(&format!( + "SELECT sx, sy, sz, dx, dy, dz, start_micros, dur_ms FROM game_creature_spline \ + WHERE guid = {guid}" + )); + assert_eq!(legs.len(), 1, "{legs:?}"); + legs.remove(0) +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn a_path_renewed_mid_leg_starts_where_the_client_renders_the_mover() { + let mut shard = Standalone::start("creature-path-renewal"); + shard.publish_module(); + shard.assert_call("claim_operator", &[]); + shard.assert_call("install_guid_range", &["0"]); + shard.assert_call("debug_seed_scenario_fixtures", &[]); + shard.assert_call("debug_spawn_player_entity", &["1"]); + shard.assert_sql("DELETE FROM game_world_entity WHERE entry = 51000"); + shard.assert_call("debug_spawn_at_feet", &["1", "51000", "5"]); + // With no Character left, the wolf stays out of combat, so its stored row may lag its leg. + shard.assert_sql("DELETE FROM game_world_entity WHERE guid = 1"); + + let wolf = shard.query_rows("SELECT * FROM game_world_entity WHERE entry = 51000"); + assert_eq!(wolf.len(), 1, "{wolf:?}"); + let guid = wolf[0]["guid"].clone(); + let (x, y, z): (f32, f32, f32) = ( + number(&wolf[0], "x"), + number(&wolf[0], "y"), + number(&wolf[0], "z"), + ); + let (grid_x, _) = grid_cell(x, y); + let centre_x = MAP_COORD_MAX - (grid_x as f32 + 0.5) * GRID_CELL_SIZE; + let destination = [ + (x + LEG_YD.copysign(centre_x - x)).to_string(), + y.to_string(), + z.to_string(), + ]; + let path_args = [ + guid.as_str(), + &destination[0], + &destination[1], + &destination[2], + "false", + ]; + + shard.assert_call("debug_creature_path", &path_args); + let first = leg(&shard, &guid); + let dur_ms: u32 = number(&first, "dur_ms"); + assert!(dur_ms >= 4_000, "{LEG_YD} yd walked in only {dur_ms} ms"); + + // Renew part way along the leg, at no particular phase of the 0.5 s firing. + std::thread::sleep(Duration::from_millis(1_730)); + shard.assert_call("debug_creature_path", &path_args); + let renewed = leg(&shard, &guid); + + let walked = ((number::(&renewed, "start_micros") - number::(&first, "start_micros")) + as f32 + / (dur_ms as f32 * 1_000.0)) + .clamp(0.0, 1.0); + assert!( + walked > 0.0 && walked < 1.0, + "the renewal must land inside the first leg" + ); + let lerp = |from: &str, to: &str| { + let (from, to): (f32, f32) = (number(&first, from), number(&first, to)); + from + (to - from) * walked + }; + let rendered = (lerp("sx", "dx"), lerp("sy", "dy"), lerp("sz", "dz")); + let start: (f32, f32, f32) = ( + number(&renewed, "sx"), + number(&renewed, "sy"), + number(&renewed, "sz"), + ); + assert!( + (start.0 - rendered.0).abs() < 0.01 + && (start.1 - rendered.1).abs() < 0.01 + && (start.2 - rendered.2).abs() < 0.01, + "the renewed path must start at {rendered:?}, where the client rendered the wolf, but it \ + starts at {start:?}" + ); +}