From 45d5ebbb9cef2c14a29d1f12dd594c943370148c Mon Sep 17 00:00:00 2001 From: Mathias Hartveit Date: Fri, 2 Oct 2026 18:49:37 +0200 Subject: [PATCH 1/3] fix(creatures): stop a mover where the client renders it, not at the last firing A stop between two advance firings wrote the position the last firing stored. The client had already moved on, so the mover snapped back. Since the leg advance persist gate, an out-of-combat creature's stored row can lag its leg by up to 4 yd, so the death stop could land that far short. creatures::tick::stop_where_rendered computes the point the advance phase would commit at the stop time, moves the row there and sends the zero-duration stop at the same point. The creature death stop uses it. A Package runner stop can use it too. Co-Authored-By: Claude Opus 5.5 (1M context) --- CONTEXT.md | 3 +- docs/package-api.md | 5 + module/src/combat/death.rs | 44 ++------ module/src/creatures/cycle/ctx.rs | 5 + module/src/creatures/cycle/harness.rs | 7 +- module/src/creatures/cycle/mod.rs | 2 +- module/src/creatures/tick/mod.rs | 146 +++++++++++++++++++++++++- module/tests/creature_death_stop.rs | 118 +++++++++++++++++++++ 8 files changed, 293 insertions(+), 37 deletions(-) create mode 100644 module/tests/creature_death_stop.rs diff --git a/CONTEXT.md b/CONTEXT.md index 0b7e22bdd..f0d0f9793 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -546,7 +546,8 @@ evidence. Its endpoint is proposed movement, not proof of advancement or arrival **Route Path**: 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. +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. **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 8425c551e..fc70d5486 100644 --- a/docs/package-api.md +++ b/docs/package-api.md @@ -295,6 +295,11 @@ living body to that authority check. A pending cast or movement-suppressing crow movement. Call it before issuing a movement leg, including continuation between decision turns. The Gate reads current state and does not cancel casts or change position. +`creatures::tick::stop_where_rendered(ctx, &mut mover)` stops the mover's current leg where the +client renders it now. It moves the row to that point and sends a stop there. The caller writes the +row. A mover's stored position can lag its leg, so a stop at the stored position moves 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/module/src/combat/death.rs b/module/src/combat/death.rs index 54f06f0a1..43647bde3 100644 --- a/module/src/combat/death.rs +++ b/module/src/combat/death.rs @@ -15,8 +15,8 @@ use spacetimedb::{table, ReducerContext, Table, TimeDuration}; use spacetimedb::ScheduleAt; use crate::{ - game_aura, game_creature_spawn, game_creature_spline, game_creature_template, game_threat, - game_world_entity, WorldEntity, + game_aura, game_creature_spawn, game_creature_template, game_threat, game_world_entity, + WorldEntity, }; #[cfg(feature = "debug_reducers")] use crate::{game_corpse_loot, game_spell_cast_event}; @@ -582,15 +582,15 @@ fn kill_creature_with_attribution( // corpse toward the old destination after death (a Kobold Vermin sliding onward while dead). Same // "TOLD to halt instead of left interpolating" 0-duration stop the cycle's chase phase uses when a // creature plants to swing, but fired here instead so EVERY lethal path (flee/patrol/chase, not just - // engaged melee) gets the same treatment at this one death chokepoint. The stop lands at the - // server's authoritative death position (`target.x/y/z`, unmoved by the kill) so the corpse renders - // exactly where loot-click range judges it. + // engaged melee) gets the same treatment at this one death chokepoint. The stop lands where the + // client renders the creature at the kill, and the corpse row moves there too, so the corpse + // renders exactly where loot-click range judges it. // // Deliberately NOT deleted here too, for two independent reasons: // 1. SpacetimeDB diffs a transaction's NET effect per row, not each intermediate write — update // then delete of the SAME PK in one transaction nets out, for subscribers, as a bare delete of - // the PRE-transaction row. The stop values staged by `emit_move_spline` above would never reach - // the wire, and the gateway has no `on_delete` handler for `game_creature_spline` either + // the PRE-transaction row. The stop values staged by `stop_where_rendered` below would never + // reach the wire, and the gateway has no `on_delete` handler for `game_creature_spline` either // (`world_view.rs`), so a bare delete relays nothing — the corpse would keep sliding exactly as // before this fix. // 2. `game_creature_spline` deletion is deliberately confined to ONE chokepoint, @@ -598,32 +598,10 @@ fn kill_creature_with_attribution( // in `tick/lifecycle.rs`), which forbids new deletion sites for this table on the same grounds // Retired the old per-caller copies. Adding a second deletion path here would be exactly // the divergence this checklist exists to prevent. - // So the row is left for the existing 60s corpse-decay reap (`pass_decay` → `despawn_creature_entity`, - // a LATER transaction) to clear, same as it always has for every other creature death. This means - // the literal "no row for the dead guid" Done-when only becomes true after that reap, not - // immediately on kill — the stop-spline packet and position are what a headless check right after - // kill can assert; row absence is a 60s-later assertion. See the issue-519 comment reconciling this. - if ctx - .db - .game_creature_spline() - .guid() - .find(target_guid) - .is_some() - { - let now_ms = (ctx.timestamp.to_micros_since_unix_epoch() / 1000) as u32; - crate::creatures::tick::emit_move_spline( - ctx, - target_guid, - (target.x, target.y, target.z), - (target.x, target.y, target.z), - 0, - false, - now_ms, - target.map_id, - target.instance_id, - (target.grid_x, target.grid_y), - ); - } + // So the row is left for a LATER transaction: the next advance firing reads the zero-duration + // stop as arrived and forgets it. A headless check right after the kill can assert the stop and + // the corpse position, not row absence. + crate::creatures::tick::stop_where_rendered(ctx, &mut target); entities.guid().update(target); crate::creatures::begin_death_dispatch(ctx, target_guid, killer); // Snapshot the victim's threat table for the death payload BEFORE disengage wipes it — threat diff --git a/module/src/creatures/cycle/ctx.rs b/module/src/creatures/cycle/ctx.rs index aa78c69be..131de2dc0 100644 --- a/module/src/creatures/cycle/ctx.rs +++ b/module/src/creatures/cycle/ctx.rs @@ -101,6 +101,11 @@ impl CtxWorld<'_> { } } +/// Where `leg`'s mover renders at `now_micros`. An advance firing then would commit the same point. +pub(crate) fn rendered_point(leg: CreatureSpline, now_micros: u64) -> Point { + as_leg(leg, false).rendered_at(now_micros).0 +} + /// The spline row as the cycle reads a leg. `mover_gone` is the caller's to answer: the advance /// phase has to reap orphans, while a chaser's leg belongs to a creature it just resolved. fn as_leg(s: CreatureSpline, mover_gone: bool) -> LegInFlight { diff --git a/module/src/creatures/cycle/harness.rs b/module/src/creatures/cycle/harness.rs index f26fe0dcd..accb2b27c 100644 --- a/module/src/creatures/cycle/harness.rs +++ b/module/src/creatures/cycle/harness.rs @@ -5862,7 +5862,8 @@ fn discovery_stays_on_the_narrow_candidate_universes() { /// stands still while the client animates on; `drop_leg` no-op'd and one leg replays forever; /// `engage` no-op'd and nothing ever aggroes; `restore` no-op'd and health never comes back; /// `awake_creatures` returning an empty sweep and the world goes dormant with every test passing; -/// `settle_advances` skipping its gate and every stored creature position stays at its leg start. +/// `settle_advances` skipping its gate and every stored creature position stays at its leg start; +/// `rendered_point` drifting from `rendered_at` and a stop between firings moves the client back. /// /// Several methods are deliberately more than one expression — `place`, `engage`, `retarget`, /// `combat_healed_to`, `restore`, `face` and `take_victim` — so the pin is the exact current body @@ -5909,6 +5910,10 @@ fn the_production_adapter_is_the_pass_through_the_harness_assumes() { "e.orientation = rad; } Some(entities.guid().update(e)) } }", ), ), + ( + "pub(crate) fn rendered_point(leg: CreatureSpline, now_micros: u64) -> Point {", + "{ as_leg(leg, false).rendered_at(now_micros).0 }", + ), ( "fn as_leg(s: CreatureSpline, mover_gone: bool) -> LegInFlight {", concat!( diff --git a/module/src/creatures/cycle/mod.rs b/module/src/creatures/cycle/mod.rs index bf7a923f2..5026b140e 100644 --- a/module/src/creatures/cycle/mod.rs +++ b/module/src/creatures/cycle/mod.rs @@ -30,7 +30,7 @@ mod ctx; #[cfg(test)] mod harness; -pub(crate) use ctx::run; +pub(crate) use ctx::{rendered_point, run}; /// A world point a behavior decision moves a creature to. #[derive(Clone, Copy, PartialEq, Debug)] diff --git a/module/src/creatures/tick/mod.rs b/module/src/creatures/tick/mod.rs index 22f8c6083..13957942c 100644 --- a/module/src/creatures/tick/mod.rs +++ b/module/src/creatures/tick/mod.rs @@ -9,7 +9,8 @@ //! active-cell sweep and rows-visited evidence logs, the shared candidate gate //! `movable_creature`, the rout predicates, and the one spline writer //! (`emit_move_spline`/`emit_creature_leg`) every movement decision funnels through, with -//! the persist gate for a leg advance (`advance_needs_persist`). +//! the stop between firings (`stop_where_rendered`) and the persist gate for a leg advance +//! (`advance_needs_persist`). //! - [`lifecycle`] — the canonical despawn checklist + decay/respawn/GO-respawn, the //! due-time passes that run regardless of proximity. @@ -750,6 +751,52 @@ pub(crate) fn emit_creature_leg( ctx.db.game_world_entity().guid().update(e); } +/// Stop `mover`'s leg where the client renders it now. The row moves to that point and a +/// zero-duration stop there replaces the leg, so the stored position and the stop packet agree +/// with the client. Without this, a stop between two advance firings holds the position the last +/// firing wrote, behind the client. 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 { + return; + }; + let now_micros = ctx.timestamp.to_micros_since_unix_epoch() as u64; + // The client ignores a spline id that does not exceed the one of the leg it replaces. + let spline_id = ((now_micros / 1000) as u32).max(leg.spline_id.wrapping_add(1)); + let at = move_to_rendered_point(mover, leg, now_micros); + emit_move_spline( + ctx, + mover.guid, + at, + at, + 0, + false, + spline_id, + mover.map_id, + mover.instance_id, + (mover.grid_x, mover.grid_y), + ); +} + +/// Move `mover` to where `leg` renders it at `now_micros`, grid address and packed cell included, +/// and return that point. A non-finite point leaves the row where it is, as the advance phase does. +fn move_to_rendered_point( + mover: &mut WorldEntity, + leg: CreatureSpline, + now_micros: u64, +) -> (f32, f32, f32) { + let at = super::cycle::rendered_point(leg, now_micros); + if crate::creatures::ai::finite_point(at.x, at.y, at.z) { + let (grid_x, grid_y) = spatial::grid_cell(at.x, at.y); + mover.x = at.x; + mover.y = at.y; + mover.z = at.z; + mover.grid_x = grid_x; + mover.grid_y = grid_y; + mover.cell = spatial::grid_cell_id(grid_x, grid_y); + } + (mover.x, mover.y, mover.z) +} + /// Must a leg advance stay in the creature's stored row once the firing ends? The cycle's passes /// always read the advanced row; only the commit log may skip it. `stored` is the row before the /// advance, `advanced` the row it wrote, `settled` the row at the end of the firing, and `leg` the @@ -1091,6 +1138,103 @@ mod advance_persist_gate { } } +#[cfg(test)] +mod stop_between_firings { + use super::{move_to_rendered_point, CreaturePath, CreaturePathPoint, CreatureSpline}; + use crate::nav::NavigationInputs; + use crate::WorldEntity; + use lyracore_shared::spatial; + + const STARTED_MICROS: u64 = 60_000_000; + + /// A Route Path from (0, 0, 0) through (10, 0, 0) to (10, 10, 0): 20 yd in 4 s, so 5 yd/s. + /// Its first leg ends 2 s after the start. + fn route_path() -> CreatureSpline { + let point = |x, y| CreaturePathPoint { x, y, z: 0.0 }; + CreatureSpline { + guid: 42, + start_micros: STARTED_MICROS, + dur_ms: 4_000, + sx: 0.0, + sy: 0.0, + sz: 0.0, + dx: 10.0, + dy: 10.0, + dz: 0.0, + map_id: 0, + instance_id: 0, + grid_x: 0, + grid_y: 0, + cell: 0, + spline_id: (STARTED_MICROS / 1000) as u32, + run: true, + facing: false, + facing_angle: 0.0, + path: Some(CreaturePath { + points: vec![point(10.0, 0.0), point(10.0, 10.0)], + navigation: NavigationInputs { + imported_revision: None, + navigation_enabled: true, + collision_enabled: true, + coverage_enabled: true, + static_generation: None, + coverage_generation: None, + }, + }), + } + } + + /// The Character as the last firing stored it, at (`x`, `y`, 0). + fn stored_at(x: f32, y: f32) -> WorldEntity { + let (grid_x, grid_y) = spatial::grid_cell(x, y); + let mut character = crate::helpers::tests::entity(42, 0, 0, grid_x, grid_y); + character.x = x; + character.y = y; + character + } + + fn assert_at(mover: &WorldEntity, x: f32, y: f32) { + assert!( + (mover.x - x).abs() < 1e-4 && (mover.y - y).abs() < 1e-4 && mover.z == 0.0, + "expected ({x}, {y}, 0), stored ({}, {}, {})", + mover.x, + mover.y, + mover.z + ); + let (grid_x, grid_y) = spatial::grid_cell(x, y); + assert_eq!((mover.grid_x, mover.grid_y), (grid_x, grid_y)); + assert_eq!(mover.cell, spatial::grid_cell_id(grid_x, grid_y)); + } + + #[test] + fn a_stop_after_a_leg_end_holds_the_point_on_the_next_leg() { + // The last firing ran 1.5 s in, at (7.5, 0). The first leg ended at 2 s and the stop + // comes at 2.3 s, so the Character is 1.5 yd up the second leg. + let mut character = stored_at(7.5, 0.0); + let at = move_to_rendered_point(&mut character, route_path(), STARTED_MICROS + 2_300_000); + assert_at(&character, 10.0, 1.5); + assert_eq!(at, (character.x, character.y, character.z)); + } + + #[test] + fn a_stop_after_the_route_path_ends_holds_its_destination() { + // The last firing ran 3.5 s in, at (10, 7.5). The path ended at 4 s. + let mut character = stored_at(10.0, 7.5); + move_to_rendered_point(&mut character, route_path(), STARTED_MICROS + 4_200_000); + assert_at(&character, 10.0, 10.0); + } + + #[test] + fn a_non_finite_leg_leaves_the_stored_point() { + let mut leg = route_path(); + leg.path = None; + leg.dx = f32::NAN; + let mut character = stored_at(7.5, 0.0); + move_to_rendered_point(&mut character, leg, STARTED_MICROS + 2_300_000); + assert_at(&character, 7.5, 0.0); + } +} + #[cfg(test)] mod idle_bot { use super::is_idle_bot; diff --git a/module/tests/creature_death_stop.rs b/module/tests/creature_death_stop.rs new file mode 100644 index 000000000..7cdda4507 --- /dev/null +++ b/module/tests/creature_death_stop.rs @@ -0,0 +1,118 @@ +//! A creature killed on a leg stops where the client renders it at the kill, not at the position +//! the last advance firing stored. The kill 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 spacetimedb::Timestamp; +use support::Standalone; + +/// Long enough to span several firings, short enough to stay inside one 50 yd cell. +const LEG_YD: f32 = 20.0; +/// `combat::death::CORPSE_DECAY_MICROS`: the kill arms the corpse decay this long after itself. +const CORPSE_DECAY_MICROS: i64 = 60_000_000; + +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 timestamp_micros(value: &str) -> i64 { + Timestamp::parse_from_rfc3339(value) + .unwrap_or_else(|error| panic!("invalid durable timestamp {value:?}: {error}")) + .to_micros_since_unix_epoch() +} + +fn assert_stored_at(row: &Row, at: (f32, f32, f32), when: &str) { + let stored: (f32, f32, f32) = (number(row, "x"), number(row, "y"), number(row, "z")); + assert!( + (stored.0 - at.0).abs() < 0.01 + && (stored.1 - at.1).abs() < 0.01 + && (stored.2 - at.2).abs() < 0.01, + "{when}: the corpse must rest at {at:?}, where the client rendered the kill, but it is at \ + {stored:?}" + ); +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn a_creature_killed_mid_leg_stops_where_the_client_renders_it() { + let mut shard = Standalone::start("creature-death-stop"); + 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 dest_x = x + LEG_YD.copysign(centre_x - x); + + shard.assert_call( + "debug_encounter_move", + &[ + &guid, + &dest_x.to_string(), + &y.to_string(), + &z.to_string(), + "false", + ], + ); + let leg = shard.query_rows(&format!( + "SELECT sx, sy, sz, dx, dy, dz, start_micros, dur_ms FROM game_creature_spline \ + WHERE guid = {guid}" + )); + assert_eq!(leg.len(), 1, "{leg:?}"); + let leg = &leg[0]; + let dur_ms: u32 = number(leg, "dur_ms"); + assert!(dur_ms >= 4_000, "{LEG_YD} yd walked in only {dur_ms} ms"); + + // Kill 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_kill_creature", &["1", &guid]); + + let spawn = shard.query_rows(&format!( + "SELECT despawn_at FROM game_creature_spawn WHERE guid = {guid}" + )); + assert_eq!(spawn.len(), 1, "{spawn:?}"); + let killed_micros = timestamp_micros(&spawn[0]["despawn_at"]) - CORPSE_DECAY_MICROS; + let started_micros: i64 = number(leg, "start_micros"); + let walked = + ((killed_micros - started_micros) as f32 / (dur_ms as f32 * 1_000.0)).clamp(0.0, 1.0); + assert!(walked < 1.0, "the kill must land before the leg ends"); + let lerp = |from: &str, to: &str| { + let (from, to): (f32, f32) = (number(leg, from), number(leg, to)); + from + (to - from) * walked + }; + let rendered = (lerp("sx", "dx"), lerp("sy", "dy"), lerp("sz", "dz")); + + let row_query = format!("SELECT * FROM game_world_entity WHERE guid = {guid}"); + let corpse = shard.query_rows(&row_query).remove(0); + assert_eq!(corpse["dead"], "true", "{corpse:?}"); + assert_stored_at(&corpse, rendered, "at the kill"); + // The next firings play the stop leg. A stop packet anywhere else would move the corpse there. + std::thread::sleep(Duration::from_millis(1_200)); + assert_stored_at( + &shard.query_rows(&row_query).remove(0), + rendered, + "after the stop leg played", + ); +} From 070177bb9799fb0006d2751a0f9927b3bc6f8599 Mon Sep 17 00:00:00 2001 From: Mathias Hartveit Date: Fri, 2 Oct 2026 22:48:16 +0200 Subject: [PATCH 2/3] fix(creatures): halt a stop at an obstruction and face the Route Path The stop between firings now follows the leg advance rules. A blocked Route Path segment or changed navigation inputs keep the halt point, so a stop never lands past an obstruction. A stop on a Route Path faces the next waypoint, as the advance does. The death stop now runs before the Loot Tag and rewards read the corpse position. The spline id floor is a pure function with a same-millisecond test, and a new test covers a stop that crosses a cell edge. Co-Authored-By: Claude Opus 5.5 (1M context) --- CONTEXT.md | 3 +- docs/package-api.md | 7 +- module/src/combat/death.rs | 36 ++--- module/src/creatures/cycle/ctx.rs | 17 ++- module/src/creatures/cycle/harness.rs | 78 ++++++++++- module/src/creatures/cycle/mod.rs | 42 +++++- module/src/creatures/tick/mod.rs | 186 +++++++++++--------------- 7 files changed, 226 insertions(+), 143 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index f0d0f9793..26824ec05 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -547,7 +547,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. +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. **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 fc70d5486..6e38b8c98 100644 --- a/docs/package-api.md +++ b/docs/package-api.md @@ -296,9 +296,10 @@ movement. Call it before issuing a movement leg, including continuation between current state and does not cancel casts or change position. `creatures::tick::stop_where_rendered(ctx, &mut mover)` stops the mover's current leg where the -client renders it now. It moves the row to that point and sends a stop there. The caller writes the -row. A mover's stored position can lag its leg, so a stop at the stored position moves the client -back. +client renders it now, facing along its Route Path. It moves the row to that point and sends a stop +there. The caller writes the row. A mover's stored position can lag its leg, so a stop at the stored +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. `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 diff --git a/module/src/combat/death.rs b/module/src/combat/death.rs index 43647bde3..4b5a8e4ef 100644 --- a/module/src/combat/death.rs +++ b/module/src/combat/death.rs @@ -553,6 +553,17 @@ fn kill_creature_with_attribution( crate::loot::tag::clear(ctx, target_guid); return true; } + // A creature killed mid-leg (flee, patrol, chase) still has an in-flight spline row. Left alone, + // the client keeps sliding the corpse toward the old destination. Stop it where the client + // renders it at the kill, before anything reads the corpse position, so the Loot Tag range, + // the rewards and loot-click range all judge the point the client shows. + // + // The stop row is not deleted here. SpacetimeDB relays a transaction's net effect per row, so + // an update and a delete of the same row reach subscribers as a bare delete, and the Gateway + // relays nothing for a deleted spline row. Spline deletion also stays in + // `despawn_creature_entity`, the canonical despawn checklist. The next advance firing reads the + // zero-duration stop as arrived and forgets it. + crate::creatures::tick::stop_where_rendered(ctx, &mut target); // Snapshot the victim's identity for the notify-hooks fired at the end of this fn — the corpse row // is mutated (and `target` moved into the update) before they fire. let victim_entry = target.entry; @@ -577,31 +588,6 @@ fn kill_creature_with_attribution( roll_corpse_loot(ctx, &mut target, target_guid, entitlement.as_ref()); target.health = 0; target.dead = true; - // A creature killed mid-leg (flee/patrol/chase) still carries an in-flight - // `game_creature_spline` row with a real duration — left alone, the client keeps interpolating the - // corpse toward the old destination after death (a Kobold Vermin sliding onward while dead). Same - // "TOLD to halt instead of left interpolating" 0-duration stop the cycle's chase phase uses when a - // creature plants to swing, but fired here instead so EVERY lethal path (flee/patrol/chase, not just - // engaged melee) gets the same treatment at this one death chokepoint. The stop lands where the - // client renders the creature at the kill, and the corpse row moves there too, so the corpse - // renders exactly where loot-click range judges it. - // - // Deliberately NOT deleted here too, for two independent reasons: - // 1. SpacetimeDB diffs a transaction's NET effect per row, not each intermediate write — update - // then delete of the SAME PK in one transaction nets out, for subscribers, as a bare delete of - // the PRE-transaction row. The stop values staged by `stop_where_rendered` below would never - // reach the wire, and the gateway has no `on_delete` handler for `game_creature_spline` either - // (`world_view.rs`), so a bare delete relays nothing — the corpse would keep sliding exactly as - // before this fix. - // 2. `game_creature_spline` deletion is deliberately confined to ONE chokepoint, - // `despawn_creature_entity` (the "canonical despawn checklist" — see its doc + tripwire - // in `tick/lifecycle.rs`), which forbids new deletion sites for this table on the same grounds - // Retired the old per-caller copies. Adding a second deletion path here would be exactly - // the divergence this checklist exists to prevent. - // So the row is left for a LATER transaction: the next advance firing reads the zero-duration - // stop as arrived and forgets it. A headless check right after the kill can assert the stop and - // the corpse position, not row absence. - crate::creatures::tick::stop_where_rendered(ctx, &mut target); entities.guid().update(target); crate::creatures::begin_death_dispatch(ctx, target_guid, killer); // Snapshot the victim's threat table for the death payload BEFORE disengage wipes it — threat diff --git a/module/src/creatures/cycle/ctx.rs b/module/src/creatures/cycle/ctx.rs index 131de2dc0..46f4f7413 100644 --- a/module/src/creatures/cycle/ctx.rs +++ b/module/src/creatures/cycle/ctx.rs @@ -14,8 +14,8 @@ use super::{ run_cycle, AggroTarget, CastSink, CastWhen, Caster, Combatant, CreatureWorld, CycleOutcome, EngageSink, Engagement, FearSink, Fighter, Gait, Home, IdleCreature, IdleSink, Leg, LegInFlight, MotionSink, Panicked, Pet, PetCommand, PetOwner, PetReact, PetSink, Point, Pull, - Pursuit, PursuitSink, Recovering, RegenSink, RoutSink, Router, Sensor, SpellOption, ThreatSink, - TickContext, Waypoint, + Pursuit, PursuitSink, Recovering, RegenSink, RoutSink, Router, Sensor, SpellOption, Stop, + ThreatSink, TickContext, Waypoint, }; use crate::combat::MOVE_FLAG_FORWARD; use crate::creatures::ai::TickScope; @@ -101,9 +101,16 @@ impl CtxWorld<'_> { } } -/// Where `leg`'s mover renders at `now_micros`. An advance firing then would commit the same point. -pub(crate) fn rendered_point(leg: CreatureSpline, now_micros: u64) -> Point { - as_leg(leg, false).rendered_at(now_micros).0 +/// [`super::stop_on_leg`] for a stop outside a firing, on the mover's stored leg row. Only the +/// obstruction check reads the world, and it ignores the regen window. +pub(crate) fn stop_on_stored_leg(ctx: &ReducerContext, leg: CreatureSpline) -> Option { + let now_micros = ctx.timestamp.to_micros_since_unix_epoch() as u64; + let world = CtxWorld { + ctx, + regen_window: crate::combat::RegenWindow::new(now_micros / 1_000, 0), + advances: Vec::new(), + }; + super::stop_on_leg(&world, &as_leg(leg, false), now_micros) } /// The spline row as the cycle reads a leg. `mover_gone` is the caller's to answer: the advance diff --git a/module/src/creatures/cycle/harness.rs b/module/src/creatures/cycle/harness.rs index accb2b27c..b4df7db46 100644 --- a/module/src/creatures/cycle/harness.rs +++ b/module/src/creatures/cycle/harness.rs @@ -3654,6 +3654,74 @@ fn a_new_obstruction_stops_a_retained_path_before_committing_the_next_position() assert_eq!(w.effects()[0].dur_ms, 0); } +/// A wolf the last firing stored at `stored`, 1.5 s along a Route Path from (0, 0) through +/// (10, 0) to (10, 10): 20 yd in 4 s, so its first segment ends 2 s after the start. +fn wolf_on_a_route_path(stored: Point) -> Scenario { + let w = Scenario::new(1_500_000).creature(WOLF, stored).flying( + WOLF, + p(0.0, 0.0, 10.0), + p(10.0, 10.0, 10.0), + 0, + 4_000, + ); + w.legs.borrow_mut()[0].waypoints = vec![(10.0, 0.0, 10.0), (10.0, 10.0, 10.0)]; + w +} + +fn assert_near(at: Point, want: Point) { + assert!( + (at.x - want.x).abs() < 1e-4 && (at.y - want.y).abs() < 1e-4 && at.z == want.z, + "expected {want:?}, stopped at {at:?}" + ); +} + +#[test] +fn a_stop_after_a_segment_end_holds_the_rendered_point_facing_the_next_segment() { + // The first segment ended at 2 s and the stop comes at 2.3 s, before the next firing. + let w = wolf_on_a_route_path(p(7.5, 0.0, 10.0)); + let leg = w.legs.borrow()[0].clone(); + let stop = stop_on_leg(&w, &leg, 2_300_000).expect("a finite stop"); + assert_near(stop.at, p(10.0, 1.5, 10.0)); + assert_eq!( + stop.heading, + Some(std::f32::consts::FRAC_PI_2), + "facing along +y" + ); +} + +#[test] +fn a_stop_after_the_route_path_ends_holds_its_destination() { + let w = wolf_on_a_route_path(p(10.0, 7.5, 10.0)); + let leg = w.legs.borrow()[0].clone(); + let stop = stop_on_leg(&w, &leg, 4_200_000).expect("a finite stop"); + assert_near(stop.at, p(10.0, 10.0, 10.0)); + assert_eq!(stop.heading, None, "a landed path names no next waypoint"); +} + +#[test] +fn a_stop_on_an_obstructed_route_path_keeps_the_stored_point() { + let w = wolf_on_a_route_path(p(7.5, 0.0, 10.0)); + w.obstructed_paths.borrow_mut().insert(WOLF); + let leg = w.legs.borrow()[0].clone(); + assert_eq!( + stop_on_leg(&w, &leg, 2_300_000), + Some(Stop { + at: p(7.5, 0.0, 10.0), + heading: None, + }), + "a stop must halt where the advance would, never past the obstruction" + ); +} + +#[test] +fn a_stop_on_a_non_finite_leg_keeps_the_stored_point() { + let w = Scenario::new(1_500_000) + .creature(WOLF, p(7.5, 0.0, 10.0)) + .flying(WOLF, p(0.0, 0.0, 10.0), p(f32::NAN, 0.0, 10.0), 0, 4_000); + let leg = w.legs.borrow()[0].clone(); + assert_eq!(stop_on_leg(&w, &leg, 2_300_000), None); +} + #[test] fn crowd_control_interrupts_a_retained_path_at_its_current_turn() { let mut w = Scenario::new(1_000_000) @@ -5863,7 +5931,7 @@ fn discovery_stays_on_the_narrow_candidate_universes() { /// `engage` no-op'd and nothing ever aggroes; `restore` no-op'd and health never comes back; /// `awake_creatures` returning an empty sweep and the world goes dormant with every test passing; /// `settle_advances` skipping its gate and every stored creature position stays at its leg start; -/// `rendered_point` drifting from `rendered_at` and a stop between firings moves the client back. +/// `stop_on_stored_leg` skipping the obstruction check and a stop lands past a blocked segment. /// /// Several methods are deliberately more than one expression — `place`, `engage`, `retarget`, /// `combat_healed_to`, `restore`, `face` and `take_victim` — so the pin is the exact current body @@ -5911,8 +5979,12 @@ fn the_production_adapter_is_the_pass_through_the_harness_assumes() { ), ), ( - "pub(crate) fn rendered_point(leg: CreatureSpline, now_micros: u64) -> Point {", - "{ as_leg(leg, false).rendered_at(now_micros).0 }", + "pub(crate) fn stop_on_stored_leg(ctx: &ReducerContext, leg: CreatureSpline) -> Option {", + concat!( + "{ let now_micros = ctx.timestamp.to_micros_since_unix_epoch() as u64; let world = ", + "CtxWorld { ctx, regen_window: crate::combat::RegenWindow::new(now_micros / 1_000, 0), ", + "advances: Vec::new(), }; super::stop_on_leg(&world, &as_leg(leg, false), now_micros) }", + ), ), ( "fn as_leg(s: CreatureSpline, mover_gone: bool) -> LegInFlight {", diff --git a/module/src/creatures/cycle/mod.rs b/module/src/creatures/cycle/mod.rs index 5026b140e..f22b2c1ed 100644 --- a/module/src/creatures/cycle/mod.rs +++ b/module/src/creatures/cycle/mod.rs @@ -30,7 +30,7 @@ mod ctx; #[cfg(test)] mod harness; -pub(crate) use ctx::{rendered_point, run}; +pub(crate) use ctx::{run, stop_on_stored_leg}; /// A world point a behavior decision moves a creature to. #[derive(Clone, Copy, PartialEq, Debug)] @@ -82,6 +82,46 @@ impl LegInFlight { }; (at, t >= 1.0) } + + /// The heading from `at` toward the waypoint the mover walks to at `now_micros`, as a leg + /// advance turns it. `None` off a Route Path and once the path has landed. + fn heading_on_path(&self, at: Point, now_micros: u64) -> Option { + let (_, next) = lyracore_shared::movement_path::sample( + (self.start.x, self.start.y, self.start.z), + &self.waypoints, + spline_t(now_micros, self.started_micros, self.dur_ms), + ); + self.waypoints + .get(next) + .map(|p| (p.1 - at.y).atan2(p.0 - at.x)) + } +} + +/// Where a stop between firings leaves a mover. +#[derive(Clone, Copy, PartialEq, Debug)] +pub(crate) struct Stop { + pub at: Point, + /// The heading along the Route Path segment the mover is on. `None` keeps its heading. + pub heading: Option, +} + +/// Where a stop at `now_micros` leaves `leg`'s mover, by the rules a leg advance follows then. The +/// mover stops on the point the client renders, facing along its Route Path. If the advance would +/// halt the path instead (a blocked segment or changed navigation inputs), the stop takes the halt +/// point, so it never lands past an obstruction. `None` for a non-finite point: the mover keeps +/// its stored position. +pub(crate) fn stop_on_leg(w: &impl MotionSink, leg: &LegInFlight, now_micros: u64) -> Option { + let (at, _) = leg.rendered_at(now_micros); + if let Some(halted) = w.path_obstruction(leg, now_micros) { + return Some(Stop { + at: halted, + heading: None, + }); + } + finite_point(at.x, at.y, at.z).then(|| Stop { + at, + heading: leg.heading_on_path(at, now_micros), + }) } /// Everything ONE firing knows before any behavior runs: when it fires, how far a creature travels diff --git a/module/src/creatures/tick/mod.rs b/module/src/creatures/tick/mod.rs index 13957942c..49525044e 100644 --- a/module/src/creatures/tick/mod.rs +++ b/module/src/creatures/tick/mod.rs @@ -751,18 +751,24 @@ pub(crate) fn emit_creature_leg( ctx.db.game_world_entity().guid().update(e); } -/// Stop `mover`'s leg where the client renders it now. The row moves to that point and a -/// zero-duration stop there replaces the leg, so the stored position and the stop packet agree -/// with the client. Without this, a stop between two advance firings holds the position the last -/// firing wrote, behind the client. The caller writes `mover`. A mover with no leg is unchanged. +/// Stop `mover`'s leg where the client renders it now, facing along its Route Path. The row moves +/// to that point and a zero-duration stop there replaces the leg, so the stored position and the +/// stop packet agree with the client. Without this, a stop between two advance firings holds the +/// position the last firing wrote, behind the client. 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. 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 { return; }; - let now_micros = ctx.timestamp.to_micros_since_unix_epoch() as u64; - // The client ignores a spline id that does not exceed the one of the leg it replaces. - let spline_id = ((now_micros / 1000) as u32).max(leg.spline_id.wrapping_add(1)); - let at = move_to_rendered_point(mover, leg, now_micros); + 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 at = (mover.x, mover.y, mover.z); emit_move_spline( ctx, mover.guid, @@ -777,24 +783,25 @@ pub(crate) fn stop_where_rendered(ctx: &ReducerContext, mover: &mut WorldEntity) ); } -/// Move `mover` to where `leg` renders it at `now_micros`, grid address and packed cell included, -/// and return that point. A non-finite point leaves the row where it is, as the advance phase does. -fn move_to_rendered_point( - mover: &mut WorldEntity, - leg: CreatureSpline, - now_micros: u64, -) -> (f32, f32, f32) { - let at = super::cycle::rendered_point(leg, now_micros); - if crate::creatures::ai::finite_point(at.x, at.y, at.z) { - let (grid_x, grid_y) = spatial::grid_cell(at.x, at.y); - mover.x = at.x; - mover.y = at.y; - mover.z = at.z; - mover.grid_x = grid_x; - mover.grid_y = grid_y; - mover.cell = spatial::grid_cell_id(grid_x, grid_y); +/// 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 { + ((now_micros / 1000) as u32).max(previous.wrapping_add(1)) +} + +/// Move `mover` to `stop`: position, grid address and packed cell together, and the stop's heading +/// when it has one. +fn place_stopped(mover: &mut WorldEntity, stop: super::cycle::Stop) { + let (grid_x, grid_y) = spatial::grid_cell(stop.at.x, stop.at.y); + mover.x = stop.at.x; + mover.y = stop.at.y; + mover.z = stop.at.z; + mover.grid_x = grid_x; + mover.grid_y = grid_y; + mover.cell = spatial::grid_cell_id(grid_x, grid_y); + if let Some(heading) = stop.heading { + mover.orientation = heading; } - (mover.x, mover.y, mover.z) } /// Must a leg advance stay in the creature's stored row once the firing ends? The cycle's passes @@ -1140,98 +1147,67 @@ mod advance_persist_gate { #[cfg(test)] mod stop_between_firings { - use super::{move_to_rendered_point, CreaturePath, CreaturePathPoint, CreatureSpline}; - use crate::nav::NavigationInputs; + use super::{next_spline_id, place_stopped}; + use crate::creatures::cycle::{Point, Stop}; use crate::WorldEntity; use lyracore_shared::spatial; - const STARTED_MICROS: u64 = 60_000_000; - - /// A Route Path from (0, 0, 0) through (10, 0, 0) to (10, 10, 0): 20 yd in 4 s, so 5 yd/s. - /// Its first leg ends 2 s after the start. - fn route_path() -> CreatureSpline { - let point = |x, y| CreaturePathPoint { x, y, z: 0.0 }; - CreatureSpline { - guid: 42, - start_micros: STARTED_MICROS, - dur_ms: 4_000, - sx: 0.0, - sy: 0.0, - sz: 0.0, - dx: 10.0, - dy: 10.0, - dz: 0.0, - map_id: 0, - instance_id: 0, - grid_x: 0, - grid_y: 0, - cell: 0, - spline_id: (STARTED_MICROS / 1000) as u32, - run: true, - facing: false, - facing_angle: 0.0, - path: Some(CreaturePath { - points: vec![point(10.0, 0.0), point(10.0, 10.0)], - navigation: NavigationInputs { - imported_revision: None, - navigation_enabled: true, - collision_enabled: true, - coverage_enabled: true, - static_generation: None, - coverage_generation: None, - }, - }), - } - } - - /// The Character as the last firing stored it, at (`x`, `y`, 0). - fn stored_at(x: f32, y: f32) -> WorldEntity { - let (grid_x, grid_y) = spatial::grid_cell(x, y); - let mut character = crate::helpers::tests::entity(42, 0, 0, grid_x, grid_y); - character.x = x; - character.y = y; - character - } - - fn assert_at(mover: &WorldEntity, x: f32, y: f32) { - assert!( - (mover.x - x).abs() < 1e-4 && (mover.y - y).abs() < 1e-4 && mover.z == 0.0, - "expected ({x}, {y}, 0), stored ({}, {}, {})", - mover.x, - mover.y, - mover.z - ); - let (grid_x, grid_y) = spatial::grid_cell(x, y); - assert_eq!((mover.grid_x, mover.grid_y), (grid_x, grid_y)); - assert_eq!(mover.cell, spatial::grid_cell_id(grid_x, grid_y)); + /// A creature the last firing stored at (`x`, 0, 0), in the cell that point lies in. + fn creature_stored_at(x: f32) -> WorldEntity { + let (grid_x, grid_y) = spatial::grid_cell(x, 0.0); + let mut creature = crate::helpers::tests::entity(42, 0, 0, grid_x, grid_y); + creature.x = x; + creature.cell = spatial::grid_cell_id(grid_x, grid_y); + creature } #[test] - fn a_stop_after_a_leg_end_holds_the_point_on_the_next_leg() { - // The last firing ran 1.5 s in, at (7.5, 0). The first leg ended at 2 s and the stop - // comes at 2.3 s, so the Character is 1.5 yd up the second leg. - let mut character = stored_at(7.5, 0.0); - let at = move_to_rendered_point(&mut character, route_path(), STARTED_MICROS + 2_300_000); - assert_at(&character, 10.0, 1.5); - assert_eq!(at, (character.x, character.y, character.z)); + fn a_stop_past_a_cell_edge_moves_the_creature_into_the_next_cell() { + // x = 16.67 is a cell edge. The last firing stored the creature short of it. + let mut creature = creature_stored_at(15.0); + let before = (creature.grid_x, creature.cell); + place_stopped( + &mut creature, + Stop { + at: Point { + x: 20.0, + y: 0.0, + z: 0.0, + }, + heading: Some(1.5), + }, + ); + let (grid_x, grid_y) = spatial::grid_cell(20.0, 0.0); + assert_ne!(before.0, grid_x, "the stop must cross the cell edge"); + assert_eq!((creature.x, creature.y, creature.z), (20.0, 0.0, 0.0)); + assert_eq!((creature.grid_x, creature.grid_y), (grid_x, grid_y)); + assert_eq!(creature.cell, spatial::grid_cell_id(grid_x, grid_y)); + assert_ne!(creature.cell, before.1); + assert_eq!(creature.orientation, 1.5); } #[test] - fn a_stop_after_the_route_path_ends_holds_its_destination() { - // The last firing ran 3.5 s in, at (10, 7.5). The path ended at 4 s. - let mut character = stored_at(10.0, 7.5); - move_to_rendered_point(&mut character, route_path(), STARTED_MICROS + 4_200_000); - assert_at(&character, 10.0, 10.0); + fn a_stop_without_a_heading_keeps_the_creatures_facing() { + let mut creature = creature_stored_at(15.0); + creature.orientation = 0.7; + place_stopped( + &mut creature, + Stop { + at: Point { + x: 15.0, + y: 0.0, + z: 0.0, + }, + heading: None, + }, + ); + assert_eq!(creature.orientation, 0.7); } #[test] - fn a_non_finite_leg_leaves_the_stored_point() { - let mut leg = route_path(); - leg.path = None; - leg.dx = f32::NAN; - let mut character = stored_at(7.5, 0.0); - move_to_rendered_point(&mut character, leg, STARTED_MICROS + 2_300_000); - assert_at(&character, 7.5, 0.0); + fn a_stop_in_the_same_millisecond_as_its_leg_still_gets_a_newer_spline_id() { + assert_eq!(next_spline_id(60_000_400, 60_000), 60_001); + assert_eq!(next_spline_id(60_000_400, 59_000), 60_000); } } From 4124e9bb8f90231313ace8517a6f6ff2fcd71831 Mon Sep 17 00:00:00 2001 From: Mathias Hartveit Date: Fri, 2 Oct 2026 22:54:36 +0200 Subject: [PATCH 3/3] refactor(creatures): share the Route Path heading between advance and stop commit_position now asks LegInFlight::heading_on_path for its heading, the same call a stop makes, so the two cannot disagree on facing. The death-order test now also requires the stop before the Loot Tag reads the corpse position. Co-Authored-By: Claude Opus 5.5 (1M context) --- module/src/combat/death.rs | 1 + module/src/creatures/cycle/ctx.rs | 19 ++----------------- module/src/creatures/cycle/harness.rs | 8 +++----- 3 files changed, 6 insertions(+), 22 deletions(-) diff --git a/module/src/combat/death.rs b/module/src/combat/death.rs index 4b5a8e4ef..f3cb76083 100644 --- a/module/src/combat/death.rs +++ b/module/src/combat/death.rs @@ -1364,6 +1364,7 @@ mod lethality_tests { assert_in_order( &body, [ + "crate::creatures::tick::stop_where_rendered(", "crate::loot::tag::death_entitlement(", "crate::creatures::begin_death_dispatch", "disengage(ctx, target_guid)", diff --git a/module/src/creatures/cycle/ctx.rs b/module/src/creatures/cycle/ctx.rs index 46f4f7413..0e3cf3eae 100644 --- a/module/src/creatures/cycle/ctx.rs +++ b/module/src/creatures/cycle/ctx.rs @@ -251,23 +251,8 @@ impl MotionSink for CtxWorld<'_> { .guid() .find(guid) .and_then(|s| { - let points: Vec<_> = s - .path - .as_ref()? - .points - .iter() - .map(|p| (p.x, p.y, p.z)) - .collect(); - let (_, next) = lyracore_shared::movement_path::sample( - (s.sx, s.sy, s.sz), - &points, - super::spline_t( - self.ctx.timestamp.to_micros_since_unix_epoch() as u64, - s.start_micros, - s.dur_ms, - ), - ); - points.get(next).map(|p| (p.1 - at.y).atan2(p.0 - at.x)) + as_leg(s, false) + .heading_on_path(at, self.ctx.timestamp.to_micros_since_unix_epoch() as u64) }); let stored = self.ctx.db.game_world_entity().guid().find(guid); if let (Some(stored), Some(advanced)) = diff --git a/module/src/creatures/cycle/harness.rs b/module/src/creatures/cycle/harness.rs index b4df7db46..92962ad1c 100644 --- a/module/src/creatures/cycle/harness.rs +++ b/module/src/creatures/cycle/harness.rs @@ -6040,11 +6040,9 @@ fn the_production_adapter_is_the_pass_through_the_harness_assumes() { "crate::spell::is_self_movement_suppressed(self.ctx, guid) || ", "eventai::movement::intent(self.ctx, guid).is_some_and(|intent| intent.immobilized) } fn ", "commit_position(&mut self, guid: u64, at: Point, moved_ms: u32) { let heading = self .ctx ", - ".db .game_creature_spline() .guid() .find(guid) .and_then(|s| { let points: Vec<_> = s ", - ".path .as_ref()? .points .iter() .map(|p| (p.x, p.y, p.z)) .collect(); let (_, next) = ", - "lyracore_shared::movement_path::sample( (s.sx, s.sy, s.sz), &points, super::spline_t( ", - "self.ctx.timestamp.to_micros_since_unix_epoch() as u64, s.start_micros, s.dur_ms, ), ); ", - "points.get(next).map(|p| (p.1 - at.y).atan2(p.0 - at.x)) }); let stored = ", + ".db .game_creature_spline() .guid() .find(guid) .and_then(|s| { as_leg(s, false) ", + ".heading_on_path(at, self.ctx.timestamp.to_micros_since_unix_epoch() as u64) }); ", + "let stored = ", "self.ctx.db.game_world_entity().guid().find(guid); if let (Some(stored), ", "Some(advanced)) = (stored, self.place(guid, at, Some(moved_ms), heading)) { ", "self.advances.push((stored, advanced)); } } fn halt(&mut self, leg: &LegInFlight, at: Point, spline_id: ",