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 module/src/creatures/cycle/ctx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1011,8 +1011,8 @@ impl PetSink for CtxWorld<'_> {
}

impl CreatureWorld for CtxWorld<'_> {
fn awake_creatures(&self, scope: &TickScope) -> TickSweep {
tick::active_cell_creatures(self.ctx, scope)
fn awake_creatures(&self, scope: &TickScope, sense: bool) -> TickSweep {
tick::active_cell_creatures(self.ctx, scope, sense)
}
fn run_due_world_maintenance(&mut self) -> Vec<(&'static str, u64)> {
vec![
Expand Down
6 changes: 3 additions & 3 deletions module/src/creatures/cycle/harness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1432,7 +1432,7 @@ fn native_test_instruction(action: &RuleAction) -> CreatureInstruction {
}

impl CreatureWorld for Scenario {
fn awake_creatures(&self, _scope: &TickScope) -> TickSweep {
fn awake_creatures(&self, _scope: &TickScope, _sense: bool) -> TickSweep {
*self.seen_by_sweep.borrow_mut() = self.snapshot();
let awake = self.awake.borrow();
TickSweep {
Expand Down Expand Up @@ -6136,8 +6136,8 @@ fn the_production_adapter_is_the_pass_through_the_harness_assumes() {
(
"impl CreatureWorld for CtxWorld<'_> {",
concat!(
"{ fn awake_creatures(&self, scope: &TickScope) -> TickSweep { ",
"tick::active_cell_creatures(self.ctx, scope) } fn ",
"{ fn awake_creatures(&self, scope: &TickScope, sense: bool) -> TickSweep { ",
"tick::active_cell_creatures(self.ctx, scope, sense) } fn ",
"run_due_world_maintenance(&mut self) -> Vec<(&'static str, u64)> { vec![ ",
"(\"decay*\", tick::pass_decay(self.ctx) as u64), (\"respawn*\", ",
"tick::pass_respawn(self.ctx) as u64), ( \"go_respawn*\", ",
Expand Down
8 changes: 4 additions & 4 deletions module/src/creatures/cycle/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -673,9 +673,9 @@ pub(crate) trait CreatureWorld:
+ PetSink
+ RegenSink
{
/// The creatures near a covered player this firing, plus the pet and in-combat candidate lists
/// the same sweep harvests. Read ONCE per cycle and shared by every pass that scopes to it.
fn awake_creatures(&self, scope: &TickScope) -> TickSweep;
/// The creatures near a covered player this firing, plus candidate lists for passes due at this
/// cadence. Read once per cycle and shared by every pass that scopes to it.
fn awake_creatures(&self, scope: &TickScope, sense: bool) -> TickSweep;
/// Corpse decay, creature respawn and gameobject respawn. Not behavior — the cycle only
/// SEQUENCES them. Returns the rows each visited, for the operator log.
fn run_due_world_maintenance(&mut self) -> Vec<(&'static str, u64)>;
Expand Down Expand Up @@ -713,7 +713,7 @@ pub(crate) fn run_cycle<W: CreatureWorld>(w: &mut W, tick: TickContext) -> Cycle
active,
pets,
in_combat,
} = w.awake_creatures(&tick.scope);
} = w.awake_creatures(&tick.scope, tick.sense);
let global = tick.scope.runs_global_passes();
let mut rows: Vec<(&'static str, u64)> = Vec::new();

Expand Down
8 changes: 0 additions & 8 deletions module/src/creatures/eventai/mobility.rs
Original file line number Diff line number Diff line change
Expand Up @@ -237,14 +237,6 @@ pub(crate) fn react_state(ctx: &ReducerContext, creature_guid: u64) -> CreatureR
}
}

pub(crate) fn active_object(ctx: &ReducerContext, creature_guid: u64) -> bool {
ctx.db
.game_creature_ai_state()
.creature_guid()
.find(creature_guid)
.is_some_and(|state| state.active_object)
}

pub(crate) fn force_despawn(
ctx: &ReducerContext,
creature_guid: u64,
Expand Down
2 changes: 1 addition & 1 deletion module/src/creatures/eventai/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ pub(crate) use loader::replace_definition_for_debug;
pub(crate) use mobility::summon_life_seq;
#[cfg(test)]
pub(crate) use mobility::summon_lifetime_after;
pub(crate) use mobility::{active_object, drop_summon_expiry, ranged_posture, react_state};
pub(crate) use mobility::{drop_summon_expiry, ranged_posture, react_state};
pub use mobility::{
expire_eventai_summon, fire_eventai_forced_despawn, CreatureAiForcedDespawn,
CreatureAiSummonExpiry, CreatureAiSummonOrigin,
Expand Down
5 changes: 4 additions & 1 deletion module/src/creatures/eventai/tables.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,10 @@ pub struct CreatureAiSpellMetadata {
}

/// Creature-wide EventAI state. Edge resets advance its lifecycle identities. Module only.
#[table(accessor = game_creature_ai_state)]
#[table(
accessor = game_creature_ai_state,
index(accessor = by_active_object, btree(columns = [active_object]))
)]
pub struct CreatureAiState {
#[primary_key]
pub creature_guid: u64,
Expand Down
81 changes: 46 additions & 35 deletions module/src/creatures/tick/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@
use lyracore_shared::spatial;
use spacetimedb::{log, reducer, table, ReducerContext, ScheduleAt, Table, Timestamp};

use crate::{game_aura, game_entity_motion, game_melee_attack, game_world_entity, WorldEntity};
use crate::{
game_aura, game_creature_ai_state, game_entity_motion, game_melee_attack, game_world_entity,
WorldEntity,
};

use super::*;

Expand Down Expand Up @@ -309,52 +312,63 @@ fn active_cell_radius(ctx: &ReducerContext) -> f32 {
/// Reuses `helpers::entities_near` (the existing, already-tested `by_grid`-indexed neighborhood query
/// from work-item 190 slice 1 — until now unused) per player, so the query is instance-isolated for
/// free. A creature absent from this set is DORMANT this tick for every pass that consults it — see
/// the classification in `tick_creatures`'s doc comment. With zero players online the set is empty
/// (every creature dormant); with players online the cost scales with player density, not world size.
/// the classification in `tick_creatures`'s doc comment. The neighborhood-query cost scales with
/// Character density, not world size; authored active objects remain awake without a Character.
///
/// NOTE on the one remaining full-table touch: locating the players themselves still needs
/// `entities.iter().filter(is_player)` — there's no `by_type_mask` (or dedicated players-only) index
/// in the current schema, so this reads every row once. That's the SAME pattern the cycle's aggro
/// phase still uses to build its own player snapshot (this fn doesn't share that one — a further,
/// independent micro-optimization) — but it's a bare bit-check per row, not the expensive per-creature
/// template/faction/stealth logic this item exists to stop running on out-of-range creatures. The
/// rows-visited reduction this item measures is that expensive-logic population, not this cheap scan.
/// Character seeds come from the `entry == 0` index and retain the PLAYER-bit check, so legal type-mask
/// variants remain eligible. Active objects come from their EventAI-state index and are point-read by
/// guid. The residual whole-entity discovery is needed only for pets and exact IN_COMBAT bit masks;
/// it therefore runs on sense firings rather than every movement firing.
/// Work-item 229: seeds ONLY from players in instances THIS firing's scope covers — `entities_near`
/// is already instance-gated (190 slice 1), so the returned set then contains only covered-instance
/// creatures, which scopes patrol/aggro+assist/return/wander without touching their bodies. With
/// only the seeded catch-all row, `covers()` is `true` for every player → identical set to pre-229.
pub(crate) fn active_cell_creatures(ctx: &ReducerContext, scope: &TickScope) -> TickSweep {
pub(crate) fn active_cell_creatures(
ctx: &ReducerContext,
scope: &TickScope,
sense: bool,
) -> TickSweep {
let entities = ctx.db.game_world_entity();
let radius = active_cell_radius(ctx);
let mut out = std::collections::HashSet::new();
// Perf catalog 1.10 + 1.7: the pet phase and the combat-exit pass each used to run their OWN full
// `entities.iter()` scan per sense tick — one for `owner_guid != 0`, one for the IN_COMBAT bit.
// This scan is already mandatory (it locates the players the active-cell set is built from) and
// already visits every row, so both guid lists ride along for the cost of two bit tests: no new
// table, no lifecycle hooks, and no index maintenance on the hottest write path in the tick (the
// trade the pet pass's own doc rightly rejected). Collected in table order, so both passes visit the
// same candidates in the same order as their old dedicated scans.
let mut pets: Vec<u64> = Vec::new();
let mut in_combat: Vec<u64> = Vec::new();
let players: Vec<WorldEntity> = entities
.iter()
.filter(|e| {

// Pet behavior consumes its candidates only on sense firings. The global combat-drop and regen
// passes consume IN_COMBAT candidates on global sense firings. Keep their shared table-order scan
// on that cadence; non-sense movement firings no longer read the world to build unused lists.
if sense {
let global = scope.runs_global_passes();
for e in entities.iter() {
if e.owner_guid != 0 {
pets.push(e.guid);
}
if e.unit_flags & lyracore_shared::constants::unit_flags::IN_COMBAT != 0 {
if global
&& e.unit_flags & lyracore_shared::constants::unit_flags::IN_COMBAT != 0
{
in_combat.push(e.guid);
}
if active_object_enters_scope(
e.is_player(),
scope.covers(e.instance_id),
crate::creatures::active_object(ctx, e.guid),
) {
out.insert(e.guid);
}
e.is_player() && scope.covers(e.instance_id)
})
}
}

let players: Vec<WorldEntity> = entities
.by_entry()
.filter(&0u32)
.filter(|e| e.is_player() && scope.covers(e.instance_id))
.collect();
for state in ctx
.db
.game_creature_ai_state()
.by_active_object()
.filter(&true)
{
let Some(e) = entities.guid().find(state.creature_guid) else {
continue;
};
if active_object_enters_scope(e.is_player(), scope.covers(e.instance_id), true) {
out.insert(e.guid);
}
}
for p in players {
for c in crate::helpers::entities_near(ctx, p.map_id, p.instance_id, p.x, p.y, radius) {
// `cell_is_active` is a belt-and-suspenders re-check of the SAME predicate `entities_near`'s
Expand Down Expand Up @@ -389,10 +403,7 @@ fn active_object_enters_scope(is_player: bool, partition_covered: bool, active:
!is_player && partition_covered && active
}

/// Everything ONE pass over `game_world_entity` yields for a firing of `tick_creatures` — the
/// active-cell creature set plus the two small candidate lists that used to cost a dedicated full scan
/// each (perf catalog 1.7 / 1.10). Every field is derived from the SAME row visit, so adding a
/// consumer costs a bit test, not a scan.
/// One firing's active-cell creature set plus the sense-cadence pet and in-combat candidate lists.
#[derive(Default)]
pub(crate) struct TickSweep {
/// Creatures within `active_cell_radius` of at least one covered player (work-item 230).
Expand Down
5 changes: 4 additions & 1 deletion module/src/world.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,10 @@ pub(crate) fn clear_relay_world_states_for_instance(ctx: &ReducerContext, instan
// `entity_by_owner` is the auth prologue of ~77 player reducer call sites; without this it was a
// full table scan per transaction (perf catalog 1.2). `owner_identity` never changes for a live
// row, so maintenance is insert/delete-only.
index(accessor = by_owner, btree(columns = [owner_identity]))
index(accessor = by_owner, btree(columns = [owner_identity])),
// Character rows have protocol entry 0. The Creature cycle uses this as a sparse seed, then keeps
// the authoritative PLAYER-bit check so additional type-mask bits remain valid.
index(accessor = by_entry, btree(columns = [entry]))
)]
pub struct WorldEntity {
#[primary_key]
Expand Down
Loading