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: 3 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -546,7 +546,9 @@ 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, 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
6 changes: 6 additions & 0 deletions docs/package-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,12 @@ 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, 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
the landing coordinates private. `actor::enter_sessionless_areatrigger` rechecks the current body,
Expand Down
63 changes: 14 additions & 49 deletions module/src/combat/death.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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;
Expand All @@ -577,53 +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 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.
//
// 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
// (`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 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),
);
}
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
Expand Down Expand Up @@ -1400,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)",
Expand Down
35 changes: 16 additions & 19 deletions module/src/creatures/cycle/ctx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -101,6 +101,18 @@ impl CtxWorld<'_> {
}
}

/// [`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<Stop> {
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
/// 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 {
Expand Down Expand Up @@ -239,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)) =
Expand Down
87 changes: 81 additions & 6 deletions module/src/creatures/cycle/harness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -5862,7 +5930,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;
/// `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
Expand Down Expand Up @@ -5909,6 +5978,14 @@ fn the_production_adapter_is_the_pass_through_the_harness_assumes() {
"e.orientation = rad; } Some(entities.guid().update(e)) } }",
),
),
(
"pub(crate) fn stop_on_stored_leg(ctx: &ReducerContext, leg: CreatureSpline) -> Option<Stop> {",
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 {",
concat!(
Expand Down Expand Up @@ -5963,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: ",
Expand Down
42 changes: 41 additions & 1 deletion module/src/creatures/cycle/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ mod ctx;
#[cfg(test)]
mod harness;

pub(crate) use ctx::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)]
Expand Down Expand Up @@ -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<f32> {
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<f32>,
}

/// 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<Stop> {
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
Expand Down
Loading
Loading