Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions docs/package-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
91 changes: 91 additions & 0 deletions gateway/src/stdb/bindings/debug_creature_path_reducer.rs
Original file line number Diff line number Diff line change
@@ -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<DebugCreaturePathArgs> 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<Result<(), String>, __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<Result<(), String>, __sdk::InternalError>)
+ Send
+ 'static,
) -> __sdk::Result<()> {
self.imp
.invoke_reducer_with_callback(DebugCreaturePathArgs { guid, x, y, z, run }, callback)
}
}
23 changes: 23 additions & 0 deletions gateway/src/stdb/bindings/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
30 changes: 20 additions & 10 deletions module/src/creatures/tick/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -658,22 +660,25 @@ 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 {
return;
}
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,
start,
destination,
duration,
run,
now_ms,
spline_id,
mover.map_id,
mover.instance_id,
(mover.grid_x, mover.grid_y),
Expand Down Expand Up @@ -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,
Expand All @@ -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<u32> {
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 {
Expand Down
17 changes: 17 additions & 0 deletions module/src/debug/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
105 changes: 105 additions & 0 deletions module/tests/creature_path_renewal.rs
Original file line number Diff line number Diff line change
@@ -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<String, String>;

fn number<T: std::str::FromStr>(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::<i64>(&renewed, "start_micros") - number::<i64>(&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:?}"
);
}
Loading