diff --git a/.github/workflows/module-durable.yml b/.github/workflows/module-durable.yml index a984d16d6..889d6119b 100644 --- a/.github/workflows/module-durable.yml +++ b/.github/workflows/module-durable.yml @@ -47,7 +47,23 @@ jobs: args=() for target in "${targets[@]}"; do args+=(--test "$target"); done echo "targets: ${targets[*]}" - cargo test -p lyracore-module "${args[@]}" -- --ignored --test-threads=4 --skip deadmines --skip lethal_floor + cargo test -p lyracore-module "${args[@]}" -- --ignored --test-threads=4 --skip deadmines --skip lethal_floor --skip populated_old_module_upgrades_without_clearing + + - name: Check out the populated-upgrade baseline + if: matrix.shard == 2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: 44b04878f2050936faa61d6f087f3aa1a7a78a46 + path: .lyracore-upgrade-baseline + persist-credentials: false + + - name: Verify the populated Module upgrade + if: matrix.shard == 2 + run: | + baseline_target="$RUNNER_TEMP/lyracore-upgrade-baseline-target" + cargo build --manifest-path .lyracore-upgrade-baseline/Cargo.toml --target-dir "$baseline_target" --release --target wasm32-unknown-unknown -p lyracore-module --features=debug_reducers,package_test_fixture + export LYRACORE_TEST_BASELINE_WASM="$baseline_target/wasm32-unknown-unknown/release/lyracore_module.wasm" + cargo test -p lyracore-module --test additive_upgrade populated_old_module_upgrades_without_clearing -- --ignored --exact --test-threads=1 - name: Run the single-shard auction Refusal regression if: matrix.shard == 0 diff --git a/crates/lyracore-shared/src/vmap.rs b/crates/lyracore-shared/src/vmap.rs index 73da865b2..bbd06a6df 100644 --- a/crates/lyracore-shared/src/vmap.rs +++ b/crates/lyracore-shared/src/vmap.rs @@ -100,8 +100,8 @@ pub enum RayFlavor { } // Triangles span cells, so choose the globally nearest hit across every crossed cell. -pub fn cast_ray( - fetch: &mut impl FnMut(u16, u16) -> Option>, +pub fn cast_ray>( + fetch: &mut impl FnMut(u16, u16) -> Option, a: [f32; 3], b: [f32; 3], flavor: RayFlavor, @@ -111,8 +111,8 @@ pub fn cast_ray( nearest_hit(fetch, a, b, want).map(|(t, _)| point_at(a, b, t)) } -pub fn cast_floor( - fetch: &mut impl FnMut(u16, u16) -> Option>, +pub fn cast_floor>( + fetch: &mut impl FnMut(u16, u16) -> Option, a: [f32; 3], b: [f32; 3], ) -> Option<[f32; 3]> { @@ -134,8 +134,8 @@ pub fn supports_walking(normal: [f32; 3], up: [f32; 3]) -> bool { && alignment * alignment >= 50.0f32.to_radians().cos().powi(2) * lengths_squared } -fn nearest_hit( - fetch: &mut impl FnMut(u16, u16) -> Option>, +fn nearest_hit>( + fetch: &mut impl FnMut(u16, u16) -> Option, a: [f32; 3], b: [f32; 3], want: impl Fn(&VmapTri) -> bool, @@ -145,7 +145,7 @@ fn nearest_hit( let Some(tris) = fetch(cx, cy) else { continue; }; - for t in &tris { + for t in tris.as_ref() { if !want(t) { continue; } @@ -176,8 +176,8 @@ pub fn group_is_indoor(mogp_flags: u32) -> bool { pub const AREA_PROBE_DOWN_YD: f32 = 10.0; -pub fn cast_ray_area( - fetch: &mut impl FnMut(u16, u16) -> Option>, +pub fn cast_ray_area>( + fetch: &mut impl FnMut(u16, u16) -> Option, a: [f32; 3], b: [f32; 3], ) -> Option { @@ -386,7 +386,7 @@ mod tests { let bottom = [0.25, 0.25, 0.0]; let mut fetch = |_, _| Some(vec![tri(5.0, class), tri(10.0, TriClass::M2)]); assert!((cast_floor(&mut fetch, top, bottom).unwrap()[2] - 10.0).abs() < 0.001); - assert!(cast_floor(&mut |_, _| None, top, bottom).is_none()); + assert!(cast_floor(&mut |_, _| None::>, top, bottom).is_none()); } #[test] @@ -678,7 +678,7 @@ mod tests { #[test] fn a_probe_with_no_wmo_geometry_in_range_finds_no_area() { - let mut fetch = |_fx: u16, _fy: u16| None; + let mut fetch = |_fx: u16, _fy: u16| None::>; assert_eq!( cast_ray_area(&mut fetch, [0.0, 0.0, 15.0], [0.0, 0.0, -5.0]), None diff --git a/docs/architecture.md b/docs/architecture.md index 8ba6f29fd..6be6d41f3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -96,6 +96,22 @@ dedicated Instance firings must not repeat them. The [decay](../module/src/creatures/tick/lifecycle.rs) arms a future respawn deadline to prevent a corpse from returning in the same firing. +Simulation candidates come from maintained WorldEntity predicates. Entity insert/update methods stamp +`regeneration_pending`, `combat_active` and `simulation_indexed` in the same transaction as the game +fields. A migrated row defaults to `simulation_indexed=false`. Each creature firing backfills at most +512 rows through an index. Creature phases pause until all rows have current predicates; other +scheduled reducers and ordinary gameplay continue. The upgrade pause grows with the existing entity +count, so test it on an isolated copy of the target Shard before rollout. Rows whose health and power +need no regeneration leave the work set; Rage stays pending until depleted. Pet sensing uses the owner +index, Character snapshots use the entry index, and dedicated Instance firings advance only that +Instance's splines. The phase order stays unchanged. +Operator SQL that changes health, power, death or combat flags must also set +`simulation_indexed=false`; ordinary reducer writes stamp these predicates automatically. + +Creature firings reuse decoded static collision cells within the firing, keyed by generation and cell. +The cache holds at most 128 cells or 4 MiB of triangles. Dynamic GameObject colliders remain fresh. +Uncached queries keep owned triangle vectors, and no cache persists across reducer transactions. + #### Navigation legs The [shared leg search](../crates/lyracore-shared/src/nav.rs) plans short movement legs. Callers diff --git a/docs/testing.md b/docs/testing.md index 769543211..9c1878b5f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -67,6 +67,11 @@ CI runs every target in three parallel jobs with `--test-threads=4 --skip deadmi lethal_floor`. The Deadmines targets need the separately installed dungeons Package, and the `lethal_floor` ranged phase is flaky. +CI runs `populated_old_module_upgrades_without_clearing` separately. It builds the Module at +`44b04878f2050936faa61d6f087f3aa1a7a78a46`, sets `LYRACORE_TEST_BASELINE_WASM` to that Wasm, +and publishes the current Module over the populated fixture without clearing it. Other durable +runs must skip that test unless the baseline Wasm is available. + `scripts/durable-test-targets.py` assigns targets by the durations in `.github/durable-test-seconds.json`. It includes every current target once and estimates 20 seconds for a new target. `JOB_SECONDS` reserves time for each job's builds, setup and Gateway checks. diff --git a/gateway/src/codec/entity/subscriptions.rs b/gateway/src/codec/entity/subscriptions.rs index e82134bce..227dfeedc 100644 --- a/gateway/src/codec/entity/subscriptions.rs +++ b/gateway/src/codec/entity/subscriptions.rs @@ -909,6 +909,9 @@ pub(crate) mod tests { sheet_ranged_ap: 0, sheet_ranged_dmg_min: 0, sheet_ranged_dmg_max: 0, + combat_active: false, + regeneration_pending: false, + simulation_indexed: false, } } diff --git a/gateway/tests/schema_parity.rs b/gateway/tests/schema_parity.rs index e3680f511..43d6ad9f6 100644 --- a/gateway/tests/schema_parity.rs +++ b/gateway/tests/schema_parity.rs @@ -658,7 +658,7 @@ parity_test!(parity_game_world_entity, "game_world_entity", lyracore_module::Wor sheet_str_bonus, sheet_agi_bonus, sheet_sta_bonus, sheet_int_bonus, sheet_spi_bonus, sheet_ap_base, sheet_ap_mods, sheet_dmg_min, sheet_dmg_max, sheet_crit_bp, unit_bytes_2, bank_bag_slots, mount_display_id, zone_id, sheet_ranged_ap, sheet_ranged_dmg_min, - sheet_ranged_dmg_max, + sheet_ranged_dmg_max, regeneration_pending, combat_active, simulation_indexed, }); parity_test!(parity_game_zone_weather, "game_zone_weather", lyracore_module::ZoneWeather, bindings::zone_weather_type::ZoneWeather, { zone_id, weather_type, intensity, changed_at_micros, diff --git a/importer/src/vmap.rs b/importer/src/vmap.rs index 0deedbfb9..2597482bb 100644 --- a/importer/src/vmap.rs +++ b/importer/src/vmap.rs @@ -1198,7 +1198,7 @@ fn walkable_floor( rows.iter() .copied() .filter(|tri| matches!(tri.class, TriClass::Wmo { .. })) - .collect() + .collect::>() }) }, top, diff --git a/module/src/actor.rs b/module/src/actor.rs index df8011b62..86f00d781 100644 --- a/module/src/actor.rs +++ b/module/src/actor.rs @@ -266,8 +266,8 @@ pub(crate) fn restore_sessionless_body( ctx: &ReducerContext, character_guid: u64, ) -> Option { - use crate::tables::{game_creature_spline, game_world_entity}; - use spacetimedb::{Identity, Table}; + use crate::tables::game_creature_spline; + use spacetimedb::Identity; if let Ok(entity) = crate::helpers::live_entity(ctx, character_guid) { return Some(entity); @@ -282,7 +282,7 @@ pub(crate) fn restore_sessionless_body( entity.player_flags = player_flags; entity.unit_bytes_1 = unit_bytes_1; } - let entity = ctx.db.game_world_entity().insert(entity); + let entity = entity.insert(ctx); ctx.db.game_creature_spline().guid().delete(character_guid); Some(entity) } @@ -302,7 +302,7 @@ pub(crate) fn stop_movement(ctx: &ReducerContext, actor_guid: u64) { } if let Some(mut entity) = ctx.db.game_world_entity().guid().find(actor_guid) { crate::creatures::tick::stop_where_rendered(ctx, &mut entity); - ctx.db.game_world_entity().guid().update(entity); + entity.update(ctx); } else { ctx.db.game_creature_spline().guid().delete(actor_guid); } diff --git a/module/src/auction.rs b/module/src/auction.rs index 4a52c88c7..0c3d3cc57 100644 --- a/module/src/auction.rs +++ b/module/src/auction.rs @@ -1761,7 +1761,7 @@ fn consume_listing_value(ctx: &ReducerContext, listing: &PreparedListing) { let mut seller = crate::helpers::acting_entity_by_guid(ctx, listing.request.seller_guid) .expect("validated listing seller remains present in the reducer transaction"); seller.money -= listing.deposit; - ctx.db.game_world_entity().guid().update(seller); + seller.update(ctx); ctx.db .game_item_instance() .guid() @@ -1920,7 +1920,7 @@ impl BidSource for CtxBidSource<'_> { .money .checked_sub(request.offer) .ok_or(AuctionRefusal::NotEnoughMoney)?; - self.ctx.db.game_world_entity().guid().update(bidder); + bidder.update(self.ctx); self.ctx.db.game_auction_bid_hold().insert(AuctionBidHold { operation_id: request.operation_id, bidder_guid: request.bidder_guid, @@ -1951,7 +1951,7 @@ impl BidSource for CtxBidSource<'_> { .ok_or(AuctionRefusal::Database)?; let (money, deferred_refund) = crate::mail::split_refund(bidder.money, refund); bidder.money = money; - self.ctx.db.game_world_entity().guid().update(bidder); + bidder.update(self.ctx); deferred_refund } else { 0 diff --git a/module/src/auction/fixtures.rs b/module/src/auction/fixtures.rs index 1ec54cbb2..efe56e138 100644 --- a/module/src/auction/fixtures.rs +++ b/module/src/auction/fixtures.rs @@ -474,7 +474,7 @@ pub fn debug_stage_auction_cancel_fixture( .ok_or_else(|| format!("auctioneer {auctioneer_guid} is not in the world"))?; auctioneer.faction_template = CANCEL_FIXTURE_FACTION_TEMPLATE; auctioneer.npc_flags |= lyracore_shared::constants::npc_flags::AUCTIONEER; - ctx.db.game_world_entity().guid().update(auctioneer); + auctioneer.update(ctx); } let expires_micros = ctx diff --git a/module/src/away.rs b/module/src/away.rs index 2f876f742..2902a2d2d 100644 --- a/module/src/away.rs +++ b/module/src/away.rs @@ -1,6 +1,5 @@ use spacetimedb::{table, ReducerContext, Table}; -use crate::game_world_entity; use lyracore_shared::chat::chat_kind; use lyracore_shared::constants::player_flags; @@ -74,7 +73,7 @@ pub(crate) fn apply_set_away( let character_guid = entity.guid; if change.player_flags != entity.player_flags { entity.player_flags = change.player_flags; - ctx.db.game_world_entity().guid().update(entity); + entity.update(ctx); } write_reply(ctx, character_guid, change.reply); Ok(()) diff --git a/module/src/breath.rs b/module/src/breath.rs index 52a9d18b8..0a06c0b83 100644 --- a/module/src/breath.rs +++ b/module/src/breath.rs @@ -120,7 +120,7 @@ pub fn tick_breath(ctx: &ReducerContext, _schedule: BreathSchedule) { if let Some(mut pre_death) = ctx.db.game_world_entity().guid().find(entity_guid) { pre_death.health = health; - ctx.db.game_world_entity().guid().update(pre_death); + pre_death.update(ctx); } } crate::combat::kill_player(ctx, entity_guid, entity_guid); @@ -137,7 +137,7 @@ pub fn tick_breath(ctx: &ReducerContext, _schedule: BreathSchedule) { if !died && health != entity.health { let mut hurt = entity; hurt.health = health; - ctx.db.game_world_entity().guid().update(hurt); + hurt.update(ctx); } } } diff --git a/module/src/chat.rs b/module/src/chat.rs index cc4c61d05..4fd49bf81 100644 --- a/module/src/chat.rs +++ b/module/src/chat.rs @@ -122,7 +122,7 @@ fn write_chat_event( if lyracore_shared::chat::broadcast_chat::is_zone_wide(chat_type) { sender.zone_id = crate::terrain::zone_id_at(ctx, sender.map_id, sender.x, sender.y).unwrap_or(0); - sender = ctx.db.game_world_entity().guid().update(sender); + sender = sender.update(ctx); } ctx.db.game_chat_event_live().insert(ChatEvent { id: 0, diff --git a/module/src/combat/death.rs b/module/src/combat/death.rs index d96beece6..db7a40654 100644 --- a/module/src/combat/death.rs +++ b/module/src/combat/death.rs @@ -203,7 +203,7 @@ pub fn debug_stage_lethal_damage_floor_fixture( } creature.health = 10; creature.stance = 1; - entities.guid().update(creature); + creature.update(ctx); let definition_revision = crate::creatures::current_definition_revision(ctx, creature_guid); if definition_revision.value == 0 || !set_lethal_damage_floor(ctx, creature_guid, definition_revision.value, true) @@ -292,7 +292,7 @@ pub fn debug_set_lethal_damage_floor_health_fixture( return Err("fixture health must keep the creature alive".to_string()); } creature.health = health; - entities.guid().update(creature); + creature.update(ctx); Ok(()) } @@ -493,7 +493,7 @@ fn kill_creature_with_attribution( roll_corpse_loot(ctx, &mut target, target_guid, entitlement.as_ref()); target.health = 0; target.dead = true; - entities.guid().update(target); + target.update(ctx); crate::creatures::begin_death_dispatch(ctx, target_guid, killer); // Snapshot threat before disengage clears the ledger used by death hooks. let threat_snapshot: Vec<(u64, i64)> = ctx @@ -519,7 +519,7 @@ fn kill_creature_with_attribution( if !is_engaged(ctx, killer_guid) { if let Some(mut k) = entities.guid().find(killer_guid) { k.combat_until_ms = (ctx.timestamp.to_micros_since_unix_epoch() / 1000) as u64; - entities.guid().update(k); + k.update(ctx); } } } @@ -656,7 +656,7 @@ pub(crate) fn kill_player(ctx: &ReducerContext, victim_guid: u64, killer_guid: u victim.health = 0; victim.dead = true; victim.combat_until_ms = (ctx.timestamp.to_micros_since_unix_epoch() / 1000) as u64; - entities.guid().update(victim); + victim.update(ctx); let killer = (killer_guid != 0).then_some(killer_guid); crate::creatures::begin_death_dispatch(ctx, victim_guid, killer); disengage(ctx, victim_guid); @@ -842,7 +842,7 @@ pub(crate) fn apply_hit( if is_rage_user(a) { if let Some(mut fresh) = entities.guid().find(attacker_guid) { fresh.power = (fresh.power + rage_from_damage(dmg, true)).min(fresh.max_power); - entities.guid().update(fresh); + fresh.update(ctx); } } if attacker_is_player && !target_is_player { @@ -853,7 +853,7 @@ pub(crate) fn apply_hit( if is_lethal(target.health, dmg) && duel_takes_lethal_hit(ctx, attacker_guid, &target) { target.health = 1; - entities.guid().update(target); + target.update(ctx); if let Some(duel) = crate::duel::active_duel_between(ctx, attacker_guid, target_guid) { crate::duel::complete_duel( ctx, @@ -897,7 +897,7 @@ pub(crate) fn apply_hit( if weapon && is_rage_user(&target) { target.power = (target.power + rage_from_damage(dmg, false)).min(target.max_power); } - entities.guid().update(target); + target.update(ctx); if damage.lethal_prevented { commit_death_prevention(ctx, target_guid, attacker_guid); } diff --git a/module/src/combat/engage.rs b/module/src/combat/engage.rs index c1e9e298a..f6fa5d713 100644 --- a/module/src/combat/engage.rs +++ b/module/src/combat/engage.rs @@ -23,7 +23,7 @@ pub(crate) fn enter_combat(ctx: &ReducerContext, guid: u64) { } e.combat_until_ms = now_ms + COMBAT_DROP_MS; e.unit_flags |= lyracore_shared::constants::unit_flags::IN_COMBAT; - entities.guid().update(e); + e.update(ctx); crate::creatures::distraction::clear(ctx, guid); } @@ -53,7 +53,7 @@ pub(crate) fn arm_creature_engagement( if let Some(mut creature) = entities.guid().find(creature_guid) { if creature.target_guid != target_guid { creature.target_guid = target_guid; - entities.guid().update(creature); + creature.update(ctx); } } crate::hooks::fire_on_aggro( @@ -133,7 +133,7 @@ pub(crate) fn disengage(ctx: &ReducerContext, guid: u64) { if e.unit_flags & lyracore_shared::constants::unit_flags::IN_COMBAT != 0 { e.unit_flags &= !lyracore_shared::constants::unit_flags::IN_COMBAT; e.combat_until_ms = 0; - entities.guid().update(e); + e.update(ctx); } } } @@ -161,7 +161,7 @@ pub(crate) fn stop_duel_combat(ctx: &ReducerContext, first_guid: u64, second_gui if let Some(mut entity) = entities.guid().find(guid) { entity.unit_flags &= !lyracore_shared::constants::unit_flags::IN_COMBAT; entity.combat_until_ms = 0; - entities.guid().update(entity); + entity.update(ctx); } } } @@ -171,7 +171,7 @@ pub(crate) fn clear_target(ctx: &ReducerContext, guid: u64) { if let Some(mut e) = entities.guid().find(guid) { if !e.is_player() && e.target_guid != 0 { e.target_guid = 0; - entities.guid().update(e); + e.update(ctx); } } } @@ -229,14 +229,6 @@ pub(crate) fn is_hostile_target( || crate::faction::is_hostile(ctx, attacker.faction_template, target.faction_template)) } -pub(crate) fn melee_combatant_guids(ctx: &ReducerContext) -> Vec { - ctx.db - .game_melee_attack() - .iter() - .flat_map(|a| [a.attacker_guid, a.target_guid]) - .collect() -} - #[table( accessor = game_melee_attack, public, @@ -462,7 +454,7 @@ fn face_requested_melee_target(ctx: &ReducerContext, attacker_guid: u64, target_ let partition = (attacker.map_id, attacker.instance_id); let grid = (attacker.grid_x, attacker.grid_y); attacker.orientation = orientation; - entities.guid().update(attacker); + attacker.update(ctx); crate::creatures::tick::emit_facing_spline( ctx, attacker_guid, @@ -521,7 +513,7 @@ fn start_attack( if let Some(mut creature) = ctx.db.game_world_entity().guid().find(attacker.guid) { if creature.target_guid != target_guid { creature.target_guid = target_guid; - ctx.db.game_world_entity().guid().update(creature); + creature.update(ctx); } } } diff --git a/module/src/combat/folds.rs b/module/src/combat/folds.rs index 27d1bbc66..748e71a9c 100644 --- a/module/src/combat/folds.rs +++ b/module/src/combat/folds.rs @@ -104,7 +104,7 @@ pub(crate) fn clear_queued_strike(ctx: &ReducerContext, guid: u64) { if let Some(mut e) = entities.guid().find(guid) { if e.next_swing_spell != 0 { e.next_swing_spell = 0; - entities.guid().update(e); + e.update(ctx); } } } @@ -130,7 +130,7 @@ pub(crate) fn arm_react(ctx: &ReducerContext, guid: u64, kind: ReactKind) { ReactKind::Overpower => e.overpower_until_ms = until, ReactKind::Revenge => e.revenge_until_ms = until, } - entities.guid().update(e); + e.update(ctx); } } diff --git a/module/src/combat/mod.rs b/module/src/combat/mod.rs index 4e6ce73a6..de69d871f 100644 --- a/module/src/combat/mod.rs +++ b/module/src/combat/mod.rs @@ -17,9 +17,9 @@ pub(crate) use lyracore_shared::aura::move_speed_with_pct; pub(crate) use engage::COMBAT_DROP_MS; pub(crate) use engage::{ apply_start_attack, apply_start_ranged_attack, arm_creature_engagement, break_own_attacks, - disengage, enter_combat, is_engaged, is_hostile_target, may_harm, may_help, - melee_combatant_guids, refresh_leash, request_attack, stop_attack_for, stop_duel_combat, - validate_attack_target, SWING_ERROR_BAD_FACING, SWING_ERROR_NONE, SWING_ERROR_NOT_IN_RANGE, + disengage, enter_combat, is_engaged, is_hostile_target, may_harm, may_help, refresh_leash, + request_attack, stop_attack_for, stop_duel_combat, validate_attack_target, + SWING_ERROR_BAD_FACING, SWING_ERROR_NONE, SWING_ERROR_NOT_IN_RANGE, }; pub use engage::{ game_combat_event, game_combat_event_live, game_melee_attack, game_melee_schedule, diff --git a/module/src/combat/swing.rs b/module/src/combat/swing.rs index 5d32bd265..6782b04c3 100644 --- a/module/src/combat/swing.rs +++ b/module/src/combat/swing.rs @@ -67,7 +67,7 @@ fn leash_pass(ctx: &ReducerContext) { if let Some(mut c) = entities.guid().find(creature) { if c.health != c.max_health { c.health = c.max_health; - entities.guid().update(c); + c.update(ctx); } } } @@ -443,7 +443,7 @@ fn fire_melee_swing( if let Some(mut a) = entities.guid().find(attacker_guid) { if a.power >= cost { a.power -= cost; - entities.guid().update(a); + a.update(ctx); swing_is_spell = true; } else { ctx.db.game_spell_cast_event_live().insert(SpellCastEvent { diff --git a/module/src/combat/tables.rs b/module/src/combat/tables.rs index 2315ebfd4..df9b3215e 100644 --- a/module/src/combat/tables.rs +++ b/module/src/combat/tables.rs @@ -951,6 +951,9 @@ mod tests { sheet_ranged_ap: 0, sheet_ranged_dmg_min: 0, sheet_ranged_dmg_max: 0, + regeneration_pending: false, + combat_active: false, + simulation_indexed: false, } } diff --git a/module/src/corpse.rs b/module/src/corpse.rs index 36b8c338c..b9ffba37c 100644 --- a/module/src/corpse.rs +++ b/module/src/corpse.rs @@ -2,8 +2,6 @@ use spacetimedb::{table, ReducerContext, Table, Timestamp}; use lyracore_shared::packing::unpack4; -use crate::game_world_entity; - pub const HIGHGUID_CORPSE: u64 = 0xF101; pub(crate) fn corpse_guid_for(owner_guid: u64) -> u64 { @@ -91,7 +89,6 @@ pub(crate) fn apply_reclaim_corpse( mut player: crate::WorldEntity, ) -> Result<(), String> { use lyracore_shared::constants::player_flags; - let entities = ctx.db.game_world_entity(); let corpses = ctx.db.game_corpse(); if !player.dead || player.player_flags & player_flags::GHOST == 0 { @@ -128,7 +125,7 @@ pub(crate) fn apply_reclaim_corpse( player.health = (player.max_health / 2).max(1); let player_guid = player.guid; - entities.guid().update(player); + player.update(ctx); crate::ghost::finish_resurrection(ctx, player_guid); Ok(()) } diff --git a/module/src/creatures/cycle/ctx.rs b/module/src/creatures/cycle/ctx.rs index cd8562bd1..0fbc3b39d 100644 --- a/module/src/creatures/cycle/ctx.rs +++ b/module/src/creatures/cycle/ctx.rs @@ -25,6 +25,12 @@ use crate::{ }; pub(crate) fn run(ctx: &ReducerContext, tick: TickContext) -> CycleOutcome { + if !crate::world::backfill_simulation(ctx) { + return CycleOutcome { + awake: 0, + rows_visited: Vec::new(), + }; + } let regen_window = crate::combat::RegenWindow::new( tick.now_micros / 1_000, (f64::from(tick.sense_secs) * 1_000.0).round() as u64, @@ -34,7 +40,7 @@ pub(crate) fn run(ctx: &ReducerContext, tick: TickContext) -> CycleOutcome { regen_window, advances: Vec::new(), }; - let outcome = run_cycle(&mut world, tick); + let outcome = crate::vmap::with_query_cache(|| run_cycle(&mut world, tick)); world.settle_advances(); outcome } @@ -57,7 +63,7 @@ impl CtxWorld<'_> { let leg = self.ctx.db.game_creature_spline().guid().find(stored.guid); if !tick::advance_needs_persist(&stored, &advanced, &settled, leg.as_ref(), now_micros) { - entities.guid().update(stored); + stored.update(self.ctx); } } } @@ -85,7 +91,7 @@ impl CtxWorld<'_> { if let Some(rad) = orientation { e.orientation = rad; } - Some(entities.guid().update(e)) + Some(e.update(self.ctx)) } } @@ -202,17 +208,22 @@ impl MotionSink for CtxWorld<'_> { } None } - fn legs_in_flight(&self) -> Vec { + fn legs_in_flight(&self, scope: &TickScope) -> Vec { let entities = self.ctx.db.game_world_entity(); - self.ctx - .db - .game_creature_spline() - .iter() - .map(|s| { - let gone = entities.guid().find(s.guid).is_none(); - as_leg(s, gone) - }) - .collect() + let splines = self.ctx.db.game_creature_spline(); + let rows: Box + '_> = match scope { + TickScope::Only(instance_id) => Box::new(splines.by_instance().filter(instance_id)), + TickScope::CatchAll { .. } => Box::new( + splines + .iter() + .filter(|spline| scope.covers(spline.instance_id)), + ), + }; + rows.map(|s| { + let gone = entities.guid().find(s.guid).is_none(); + as_leg(s, gone) + }) + .collect() } fn movement_suppressed(&self, guid: u64) -> bool { crate::spell::is_self_movement_suppressed(self.ctx, guid) @@ -469,7 +480,7 @@ impl IdleSink for CtxWorld<'_> { let entities = self.ctx.db.game_world_entity(); if let Some(mut e) = entities.guid().find(guid) { e.wp_target = waypoint_id; - entities.guid().update(e); + e.update(self.ctx); } } fn reached_home(&mut self, guid: u64) { @@ -496,7 +507,8 @@ impl EngageSink for CtxWorld<'_> { self.ctx .db .game_world_entity() - .iter() + .by_entry() + .filter(0u32) .filter(|e| e.is_player()) .map(|e| AggroTarget { guid: e.guid, @@ -631,27 +643,29 @@ impl EngageSink for CtxWorld<'_> { let entities = self.ctx.db.game_world_entity(); if let Some(mut e) = entities.guid().find(guid) { e.unit_flags &= !constants::unit_flags::IN_COMBAT; - entities.guid().update(e); + e.update(self.ctx); } } } impl RegenSink for CtxWorld<'_> { fn recovering(&self, flagged: &[u64]) -> Vec { - let mut in_combat = crate::combat::melee_combatant_guids(self.ctx); - in_combat.extend_from_slice(flagged); + let flagged: HashSet<_> = flagged.iter().copied().collect(); + let melee = self.ctx.db.game_melee_attack(); self.ctx .db .game_world_entity() - .iter() - .filter(|e| !e.dead && (e.health < e.max_health || e.max_power > 0)) + .by_regeneration_pending() + .filter(true) .map(|e| Recovering { guid: e.guid, health: e.health, max_health: e.max_health, power: e.power, max_power: e.max_power, - in_combat: in_combat.contains(&e.guid), + in_combat: flagged.contains(&e.guid) + || melee.attacker_guid().find(e.guid).is_some() + || melee.by_target().filter(e.guid).next().is_some(), }) .collect() } @@ -716,7 +730,7 @@ impl RegenSink for CtxWorld<'_> { } live.health = next_health; live.power = next_power; - entities.guid().update(live); + live.update(self.ctx); } } @@ -843,7 +857,7 @@ impl ThreatSink for CtxWorld<'_> { if let Some(mut c) = entities.guid().find(creature) { if c.target_guid != victim { c.target_guid = victim; - entities.guid().update(c); + c.update(self.ctx); } } } @@ -1091,7 +1105,7 @@ impl PetSink for CtxWorld<'_> { if let Some(mut p) = entities.guid().find(guid) { if p.target_guid != victim { p.target_guid = victim; - entities.guid().update(p); + p.update(self.ctx); } } } @@ -1116,7 +1130,7 @@ impl PetSink for CtxWorld<'_> { p.grid_y = gy; p.cell = spatial::grid_cell_id(gx, gy); p.last_move_ms = now_ms; - entities.guid().update(p); + p.update(self.ctx); } } diff --git a/module/src/creatures/cycle/harness.rs b/module/src/creatures/cycle/harness.rs index 1c76fd634..dfc6f8364 100644 --- a/module/src/creatures/cycle/harness.rs +++ b/module/src/creatures/cycle/harness.rs @@ -933,14 +933,20 @@ impl Scenario { started_micros: u64, dur_ms: u32, ) -> Self { + let partition = self + .creatures + .borrow() + .get(&guid) + .map(|creature| (creature.map_id, creature.instance_id)) + .unwrap_or((MAP, INSTANCE)); self.legs.borrow_mut().push(LegInFlight { guid, start, dest, started_micros, dur_ms, - map_id: MAP, - instance_id: INSTANCE, + map_id: partition.0, + instance_id: partition.1, mover_gone: !self.creatures.borrow().contains_key(&guid), waypoints: Vec::new(), }); @@ -1005,8 +1011,13 @@ impl MotionSink for Scenario { .contains(&leg.guid) .then(|| self.at(leg.guid).at) } - fn legs_in_flight(&self) -> Vec { - self.legs.borrow().clone() + fn legs_in_flight(&self, scope: &TickScope) -> Vec { + self.legs + .borrow() + .iter() + .filter(|leg| scope.covers(leg.instance_id)) + .cloned() + .collect() } fn movement_suppressed(&self, guid: u64) -> bool { self.cc(guid).2 @@ -3121,6 +3132,32 @@ fn a_creature_mid_flight_moves_to_the_point_its_client_renders() { ); } +#[test] +fn a_dedicated_firing_advances_only_its_instance_splines() { + let start = p(0.0, 0.0, 10.0); + let dest = p(10.0, 0.0, 10.0); + let mut w = Scenario::new(HALF_WAY) + .creature(WOLF, start) + .in_instance(WOLF, DEDICATED) + .flying(WOLF, start, dest, 0, LEG_MS) + .creature(PACK_MATE, start) + .flying(PACK_MATE, start, dest, 0, LEG_MS); + let tick = w.tick(false, TickScope::Only(DEDICATED)); + let outcome = run_cycle(&mut w, tick); + assert_eq!(w.at(WOLF).at, p(5.0, 0.0, 10.0)); + assert_eq!(w.at(PACK_MATE).at, start); + assert_eq!(outcome.rows_visited[0], ("advance", 1)); + + let tick = w.tick( + false, + TickScope::from_rows(crate::creatures::GLOBAL_TICK_INSTANCE, [DEDICATED]), + ); + let outcome = run_cycle(&mut w, tick); + assert_eq!(w.at(WOLF).at, p(5.0, 0.0, 10.0)); + assert_eq!(w.at(PACK_MATE).at, p(5.0, 0.0, 10.0)); + assert_eq!(outcome.rows_visited[0], ("advance", 1)); +} + #[test] fn a_later_pass_reads_the_advanced_position_not_the_leg_start() { let mut w = wolf_mid_flight(HALF_WAY) diff --git a/module/src/creatures/cycle/mod.rs b/module/src/creatures/cycle/mod.rs index cfe6d81bc..3e3ba0349 100644 --- a/module/src/creatures/cycle/mod.rs +++ b/module/src/creatures/cycle/mod.rs @@ -115,7 +115,7 @@ pub(crate) struct CycleOutcome { } pub(crate) trait MotionSink { - fn legs_in_flight(&self) -> Vec; + fn legs_in_flight(&self, scope: &TickScope) -> Vec; fn path_obstruction(&self, leg: &LegInFlight, now_micros: u64) -> Option; fn movement_suppressed(&self, guid: u64) -> bool; fn commit_position(&mut self, guid: u64, at: Point, moved_ms: u32); @@ -543,7 +543,7 @@ pub(crate) fn run_cycle(w: &mut W, tick: TickContext) -> Cycle fn advance_legs(w: &mut W, tick: &TickContext) -> usize { let mut visited = 0usize; - for leg in w.legs_in_flight() { + for leg in w.legs_in_flight(&tick.scope) { if leg.mover_gone { w.drop_leg(leg.guid); continue; diff --git a/module/src/creatures/distraction.rs b/module/src/creatures/distraction.rs index f9ba744bc..347b07a9d 100644 --- a/module/src/creatures/distraction.rs +++ b/module/src/creatures/distraction.rs @@ -52,7 +52,7 @@ pub(crate) fn distract( return; } tick::stop_facing(ctx, &mut creature, (dest.0, dest.1)); - entities.guid().update(creature); + creature.update(ctx); let row = CreatureDistraction { creature_guid, ends_ms: now_ms(ctx) + length_ms, diff --git a/module/src/creatures/eventai/loader.rs b/module/src/creatures/eventai/loader.rs index 54f9a85da..788890582 100644 --- a/module/src/creatures/eventai/loader.rs +++ b/module/src/creatures/eventai/loader.rs @@ -1070,7 +1070,7 @@ pub fn debug_stage_eventai_revision_fixture( .ok_or_else(|| format!("fixture target does not exist: {target_guid}"))?; target.health = 100_000; target.max_health = 100_000; - entities.guid().update(target); + target.update(ctx); let fights = ctx.db.game_melee_attack(); if let Some(mut fight) = fights.attacker_guid().find(creature_guid) { diff --git a/module/src/creatures/eventai/movement.rs b/module/src/creatures/eventai/movement.rs index 9f7dac636..f8a121cdc 100644 --- a/module/src/creatures/eventai/movement.rs +++ b/module/src/creatures/eventai/movement.rs @@ -290,7 +290,7 @@ pub(crate) fn apply_relay_orientation( .filter(|entity| !entity.is_player() && !entity.dead) .ok_or_else(|| format!("relay facing subject {creature_guid} is unavailable"))?; creature.orientation = orientation; - entities.guid().update(creature); + creature.update(ctx); Ok(()) } diff --git a/module/src/creatures/eventai/relay.rs b/module/src/creatures/eventai/relay.rs index 255b1fe61..ad8201f7b 100644 --- a/module/src/creatures/eventai/relay.rs +++ b/module/src/creatures/eventai/relay.rs @@ -1798,7 +1798,7 @@ pub(crate) fn run_relay_arrival(ctx: &ReducerContext, arrival: RelayArrival) { }, ); source.last_move_ms = crate::creatures::tick::now_ms(ctx); - ctx.db.game_world_entity().guid().update(source); + source.update(ctx); ctx.db .game_creature_spline() .guid() diff --git a/module/src/creatures/hunter_pet.rs b/module/src/creatures/hunter_pet.rs index 2fffeac9f..5a7151f6e 100644 --- a/module/src/creatures/hunter_pet.rs +++ b/module/src/creatures/hunter_pet.rs @@ -128,7 +128,7 @@ pub(crate) fn publish_hunter_pet_protocol( }; if flags != live.unit_flags { live.unit_flags = flags; - ctx.db.game_world_entity().guid().update(live); + live.update(ctx); } } let row = HunterPetProtocol { diff --git a/module/src/creatures/pet_progression.rs b/module/src/creatures/pet_progression.rs index b0c4a869b..2863d83ac 100644 --- a/module/src/creatures/pet_progression.rs +++ b/module/src/creatures/pet_progression.rs @@ -139,7 +139,6 @@ fn refreshed_pet_health( } fn refresh_live_level(ctx: &ReducerContext, pet: &HunterPet, level: u32) { - let entities = ctx.db.game_world_entity(); let Some(mut live) = super::pet_of(ctx, pet.owner_guid) else { return; }; @@ -153,7 +152,7 @@ fn refresh_live_level(ctx: &ReducerContext, pet: &HunterPet, level: u32) { .find(pet.creature_entry) else { live.level = level; - entities.guid().update(live); + live.update(ctx); return; }; let base_health = super::scale_health_for_rank(template.health, template.rank); @@ -167,7 +166,7 @@ fn refresh_live_level(ctx: &ReducerContext, pet: &HunterPet, level: u32) { live.level = level; live.health = health; live.max_health = max_health; - entities.guid().update(live); + live.update(ctx); } fn eligible_live_pet(pet_id: u64, kind: super::PetKind, dead: bool) -> bool { diff --git a/module/src/creatures/presentation.rs b/module/src/creatures/presentation.rs index d748eae2e..1948e9733 100644 --- a/module/src/creatures/presentation.rs +++ b/module/src/creatures/presentation.rs @@ -146,7 +146,7 @@ pub(crate) fn apply_eventai_instruction( return false; }; project(ctx, &mut entity, &base, &state); - entities.guid().update(entity); + entity.update(ctx); if table.creature_guid().find(creature_guid).is_some() { table.creature_guid().update(state); } else { @@ -234,7 +234,7 @@ pub(crate) fn clear_relay_temporary_faction(ctx: &ReducerContext, creature_guid: return; }; project(ctx, &mut entity, &base, &state); - entities.guid().update(entity); + entity.update(ctx); table.creature_guid().update(state); } @@ -250,7 +250,7 @@ pub(crate) fn apply_relay_stand_state( .filter(|entity| !entity.is_player()) .ok_or_else(|| format!("relay stand-state subject {creature_guid} is unavailable"))?; entity.unit_bytes_1 = (entity.unit_bytes_1 & !0xFF) | u32::from(stand_state); - entities.guid().update(entity); + entity.update(ctx); Ok(()) } @@ -309,7 +309,7 @@ pub(crate) fn update_template_from_relay( entity.unit_flags = template.unit_flags | in_combat; entity.base_attack_time_ms = template.base_attack_time_ms; entity.armor = template.armor; - entities.guid().update(entity); + entity.update(ctx); let _ = crate::creatures::set_creature_gossip_menu(ctx, creature_guid, None); Ok(()) } @@ -338,7 +338,7 @@ pub(crate) fn apply_relay_npc_flags( .filter(|entity| !entity.is_player()) .ok_or_else(|| format!("relay NPC-flag subject {creature_guid} is unavailable"))?; entity.npc_flags = apply_relay_flags(entity.npc_flags, flags, operation); - entities.guid().update(entity); + entity.update(ctx); Ok(()) } @@ -431,7 +431,7 @@ fn clear_if( return; }; restore_base(&mut entity, &base); - entities.guid().update(entity); + entity.update(ctx); } fn project( diff --git a/module/src/creatures/spawn.rs b/module/src/creatures/spawn.rs index 2b56a96cd..9f3b1e50a 100644 --- a/module/src/creatures/spawn.rs +++ b/module/src/creatures/spawn.rs @@ -458,7 +458,7 @@ pub(crate) fn insert_creature_entity(ctx: &spacetimedb::ReducerContext, mut enti y: entity.y, z: entity.z, }; - ctx.db.game_world_entity().insert(entity); + entity.insert(ctx); // Fire after insertion because handlers may resolve the creature by guid. crate::hooks::fire_on_creature_spawn(ctx, &payload); } @@ -557,6 +557,9 @@ pub(crate) fn build_creature_entity( sheet_ranged_ap: 0, sheet_ranged_dmg_min: 0, sheet_ranged_dmg_max: 0, + regeneration_pending: false, + combat_active: false, + simulation_indexed: false, } } @@ -678,6 +681,9 @@ pub(crate) fn build_player_entity( sheet_ranged_ap: 0, sheet_ranged_dmg_min: 0, sheet_ranged_dmg_max: 0, + regeneration_pending: false, + combat_active: false, + simulation_indexed: false, }; if let Some(flight) = ctx .db diff --git a/module/src/creatures/tick/mod.rs b/module/src/creatures/tick/mod.rs index 2f50f5778..c7a22cda9 100644 --- a/module/src/creatures/tick/mod.rs +++ b/module/src/creatures/tick/mod.rs @@ -2,8 +2,8 @@ use lyracore_shared::spatial; use spacetimedb::{log, reducer, table, ReducerContext, ScheduleAt, Table, Timestamp}; use crate::{ - game_aura, game_creature_ai_state, game_entity_motion, game_melee_attack, - game_sessionless_action_consent, game_world_entity, WorldEntity, + game_creature_ai_state, game_entity_motion, game_melee_attack, game_sessionless_action_consent, + game_world_entity, WorldEntity, }; use super::*; @@ -36,6 +36,7 @@ pub struct CreatureMoveEvent { #[table( accessor = game_creature_spline, public, + index(accessor = by_instance, btree(columns = [instance_id])), index(accessor = by_grid, btree(columns = [map_id, instance_id, grid_x, grid_y])), // AOI subscriptions require three equality columns in SpacetimeDB 2.7.1. index(accessor = by_cell, btree(columns = [map_id, instance_id, cell])) @@ -109,22 +110,15 @@ pub fn tick_creatures(ctx: &ReducerContext, schedule: CreatureMoveSchedule) { sense_secs: sense_period_secs_for_interval(interval_micros), scope, }; - let (sense, global, scope_label) = ( - tick.sense, - tick.scope.runs_global_passes(), - scope_label(&tick.scope), - ); + let (sense, scope_label) = (tick.sense, scope_label(&tick.scope)); let outcome = crate::creatures::cycle::run(ctx, tick); - if global { - log_active_cell_stats(ctx, outcome.awake); - log_narrowed_pass_stats(ctx); - } log_pass_stats( ctx, &scope_label, sense, + outcome.awake, &outcome.rows_visited, interval_micros, ); @@ -137,11 +131,9 @@ fn active_cell_radius(ctx: &ReducerContext) -> f32 { let templates = ctx.db.game_creature_template(); let cutoff = crate::creatures::ai::aggro_override_cutoff(visible); - if templates.by_aggro_range().filter(cutoff..).next().is_none() { - return visible; - } let template_aggro_max = templates - .iter() + .by_aggro_range() + .filter(cutoff..) .map(|t| t.aggro_range as f32) .fold(0.0_f32, f32::max); combat_active_radius(template_aggro_max).max(visible) @@ -162,14 +154,20 @@ pub(crate) fn active_cell_creatures( let mut in_combat: Vec = Vec::new(); if sense { - let global = scope.runs_global_passes(); - for e in entities.iter() { - if e.owner_guid != 0 { - pets.push(e.guid); - } - if global && e.unit_flags & lyracore_shared::constants::unit_flags::IN_COMBAT != 0 { - in_combat.push(e.guid); - } + pets.extend( + entities + .by_owner_guid() + .filter(1u64..) + .filter(|entity| scope.covers(entity.instance_id)) + .map(|entity| entity.guid), + ); + if scope.runs_global_passes() { + in_combat.extend( + entities + .by_combat_active() + .filter(true) + .map(|entity| entity.guid), + ); } } @@ -235,39 +233,6 @@ pub(crate) struct TickSweep { pub(crate) in_combat: Vec, } -fn log_active_cell_stats(ctx: &ReducerContext, awake: usize) { - let us = ctx.timestamp.to_micros_since_unix_epoch(); - if us.rem_euclid(ACTIVE_CELL_LOG_PERIOD_MICROS) >= MOVE_TICK_MICROS { - return; - } - let total = ctx - .db - .game_world_entity() - .iter() - .filter(|e| !e.is_player()) - .count(); - log::info!("tick_creatures active-cell: {awake}/{total} creatures visited this tick"); -} - -fn log_narrowed_pass_stats(ctx: &ReducerContext) { - let us = ctx.timestamp.to_micros_since_unix_epoch(); - if us.rem_euclid(ACTIVE_CELL_LOG_PERIOD_MICROS) >= MOVE_TICK_MICROS { - return; - } - let total_all = ctx.db.game_world_entity().iter().count(); - let melee_rows = ctx.db.game_melee_attack().iter().count(); - let fear_rows = ctx - .db - .game_aura() - .iter() - .filter(|a| a.eff_kind == crate::spell::A_CONTROL && a.eff_p0 == crate::spell::M_FEAR) - .count(); - log::info!( - "tick_creatures narrowed passes: cast/rout visit {melee_rows} melee rows, \ - fear visits {fear_rows} aura rows, vs {total_all} entities in the full catalogue" - ); -} - fn scope_label(scope: &TickScope) -> String { match scope { TickScope::CatchAll { dedicated } => { @@ -281,6 +246,7 @@ fn log_pass_stats( ctx: &ReducerContext, scope_label: &str, sense: bool, + awake: usize, rows_visited: &[(&'static str, u64)], interval_micros: i64, ) { @@ -294,7 +260,7 @@ fn log_pass_stats( .collect::>() .join(" "); log::info!( - "tick_creatures pass rows-visited: scope={scope_label} sense={sense} {body} (*=full-table scan, scales with world not instances)" + "tick_creatures pass rows-visited: scope={scope_label} sense={sense} awake={awake} {body} (*=global pass)" ); } @@ -410,7 +376,7 @@ pub(crate) fn emit_creature_path( } if length <= 0.0 { stop_where_rendered(ctx, &mut mover); - ctx.db.game_world_entity().guid().update(mover); + mover.update(ctx); return; } let speed = if run { speeds::RUN } else { speeds::WALK }; @@ -439,7 +405,7 @@ pub(crate) fn emit_creature_path( } mover.orientation = (points[0].1 - mover.y).atan2(points[0].0 - mover.x); mover.last_move_ms = now_ms(ctx); - ctx.db.game_world_entity().guid().update(mover); + mover.update(ctx); } #[allow(clippy::too_many_arguments)] @@ -478,7 +444,7 @@ pub(crate) fn emit_creature_leg( if set_leg_ends { e.leg_ends_ms = now_ms + duration_ms; } - ctx.db.game_world_entity().guid().update(e); + e.update(ctx); } pub(crate) fn stop_where_rendered(ctx: &ReducerContext, mover: &mut WorldEntity) { diff --git a/module/src/debug/mod.rs b/module/src/debug/mod.rs index cd40f72b2..9926a27da 100644 --- a/module/src/debug/mod.rs +++ b/module/src/debug/mod.rs @@ -137,7 +137,7 @@ pub fn debug_set_health(ctx: &ReducerContext, guid: u64, health: u32) -> Result< if clamped > 0 { e.health = clamped; let was_dead = e.dead; - entities.guid().update(e); + e.update(ctx); if was_dead { crate::ghost::finish_resurrection(ctx, guid); } @@ -258,7 +258,7 @@ pub fn debug_spawn_player_entity(ctx: &ReducerContext, character_guid: u64) -> R let entity = crate::creatures::build_player_entity(ctx, &character, character.owner_identity); let (level, max_health) = (entity.level, entity.max_health); - entities.insert(entity); + entity.insert(ctx); crate::mount::restore_mount_on_rebuild(ctx, character_guid); log::info!( "debug_spawn_player_entity: materialized player guid {character_guid} (lvl {level}, hp {max_health})" @@ -783,7 +783,7 @@ pub fn debug_engage( let entities = ctx.db.game_world_entity(); if let Some(mut e) = entities.guid().find(attacker_guid) { e.target_guid = target_guid; - entities.guid().update(e); + e.update(ctx); } Ok(()) } @@ -1276,7 +1276,7 @@ pub fn debug_backfill_cell_ids(ctx: &ReducerContext) -> Result<(), String> { for guid in stale { if let Some(mut e) = entities.guid().find(guid) { e.cell = spatial::grid_cell_id(e.grid_x, e.grid_y); - entities.guid().update(e); + e.update(ctx); } } @@ -1345,7 +1345,7 @@ pub fn debug_regrid(ctx: &ReducerContext) -> Result<(), String> { e.grid_x = gx; e.grid_y = gy; e.cell = lyracore_shared::spatial::grid_cell_id(gx, gy); - entities.guid().update(e); + e.update(ctx); } } log::info!("debug_regrid: re-stamped {n} entities at the current GRID_CELL_SIZE"); @@ -1558,7 +1558,7 @@ pub fn debug_set_money( let entities = ctx.db.game_world_entity(); if let Some(mut e) = entities.guid().find(character_guid) { e.money = copper; - entities.guid().update(e); + e.update(ctx); } Ok(()) } @@ -1574,7 +1574,7 @@ pub fn debug_set_power(ctx: &ReducerContext, guid: u64, power: u32) -> Result<() e.max_power = power; } e.power = power; - entities.guid().update(e); + e.update(ctx); Ok(()) } diff --git a/module/src/encounter.rs b/module/src/encounter.rs index ef6cfaa64..1276b368b 100644 --- a/module/src/encounter.rs +++ b/module/src/encounter.rs @@ -605,7 +605,7 @@ pub(crate) fn move_to_point( let dist = (dx * dx + dy * dy).sqrt(); if dist <= f32::EPSILON { crate::creatures::tick::stop_where_rendered(ctx, &mut e); - entities.guid().update(e); + e.update(ctx); return Ok(()); } crate::creatures::tick::emit_move_spline( @@ -621,7 +621,7 @@ pub(crate) fn move_to_point( (e.grid_x, e.grid_y), ); e.last_move_ms = crate::creatures::tick::now_ms(ctx); - entities.guid().update(e); + e.update(ctx); Ok(()) } diff --git a/module/src/exploration.rs b/module/src/exploration.rs index 2ce57925c..c99413d52 100644 --- a/module/src/exploration.rs +++ b/module/src/exploration.rs @@ -1,7 +1,5 @@ use spacetimedb::{table, ReducerContext, Table}; -#[cfg(feature = "debug_reducers")] -use crate::game_world_entity; use crate::xp::XpEvent; use crate::{game_xp_event, WorldEntity}; @@ -92,6 +90,6 @@ pub fn debug_explore_at( if let Some(area) = crate::terrain::area_at(ctx, map_id, x, y) { check_area_exploration(ctx, &mut e, &area); } - ctx.db.game_world_entity().guid().update(e); + e.update(ctx); Ok(()) } diff --git a/module/src/ghost.rs b/module/src/ghost.rs index 5a203455a..4aba69df0 100644 --- a/module/src/ghost.rs +++ b/module/src/ghost.rs @@ -29,7 +29,7 @@ pub(crate) fn finish_resurrection(ctx: &ReducerContext, guid: u64) { character.dead = false; character.player_flags &= !player_flags::GHOST; character.unit_bytes_1 &= !unit_vis_flags::GHOST; - ctx.db.game_world_entity().guid().update(character); + character.update(ctx); } let auras = ctx .db diff --git a/module/src/gm.rs b/module/src/gm.rs index e7e46b7e5..325cea8dc 100644 --- a/module/src/gm.rs +++ b/module/src/gm.rs @@ -259,11 +259,11 @@ pub(crate) fn apply_gm_command( match cmd { GmCommand::Speed(mult) => { caller.run_speed_mult_bp = (mult * 10_000.0).round() as u32; - entities.guid().update(caller); + caller.update(ctx); } GmCommand::God(explicit) => { caller.godmode = explicit.unwrap_or(!caller.godmode); - entities.guid().update(caller); + caller.update(ctx); } GmCommand::XpRate(mult) => { let cfg = ctx.db.game_world_config(); @@ -280,7 +280,7 @@ pub(crate) fn apply_gm_command( GmCommand::Money(copper) => { let guid = caller.guid; caller.money = copper; - entities.guid().update(caller); + caller.update(ctx); let chars = ctx.db.game_character(); if let Some(mut c) = chars.guid().find(guid) { c.money = copper; @@ -290,7 +290,7 @@ pub(crate) fn apply_gm_command( GmCommand::Heal => { caller.health = caller.max_health; caller.power = caller.max_power; - entities.guid().update(caller); + caller.update(ctx); } GmCommand::Gps => { return Err(format!( diff --git a/module/src/go_collider_fixture.rs b/module/src/go_collider_fixture.rs index c989a6ddd..669990ea2 100644 --- a/module/src/go_collider_fixture.rs +++ b/module/src/go_collider_fixture.rs @@ -64,7 +64,7 @@ fn blink(ctx: &ReducerContext, instance: u64, expected_x: f32) -> Result<(), Str caster.orientation = 0.0; (caster.grid_x, caster.grid_y) = lyracore_shared::spatial::grid_cell(caster.x, caster.y); caster.cell = lyracore_shared::spatial::cell_id_at(caster.x, caster.y); - ctx.db.game_world_entity().guid().update(caster); + caster.update(ctx); crate::spell::blink_forward(ctx, 1, 8.0); let landed = ctx .db diff --git a/module/src/guild/fee.rs b/module/src/guild/fee.rs index aabb4a863..9b27b887c 100644 --- a/module/src/guild/fee.rs +++ b/module/src/guild/fee.rs @@ -485,7 +485,7 @@ impl FeePurse for CtxPurse<'_> { .checked_sub(hold.copper) .ok_or(FeeError::Refused(GuildRefusal::NotEnoughMoney))?; let owner_identity = payer.owner_identity; - self.ctx.db.game_world_entity().guid().update(payer); + payer.update(self.ctx); if let HeldTerms::Charter { charter_item_guid, .. } = &mut hold.terms @@ -520,7 +520,7 @@ impl FeePurse for CtxPurse<'_> { .money .checked_add(credit) .ok_or(FeeError::Conflict("refund does not fit in the purse"))?; - self.ctx.db.game_world_entity().guid().update(payer); + payer.update(self.ctx); } if mailed != 0 { self.mail_refund(hold, mailed)?; diff --git a/module/src/helpers.rs b/module/src/helpers.rs index 0f1463f20..28ec0b253 100644 --- a/module/src/helpers.rs +++ b/module/src/helpers.rs @@ -267,6 +267,9 @@ pub(crate) mod tests { sheet_ranged_ap: 0, sheet_ranged_dmg_min: 0, sheet_ranged_dmg_max: 0, + regeneration_pending: false, + combat_active: false, + simulation_indexed: false, } } diff --git a/module/src/items/economy.rs b/module/src/items/economy.rs index 25d6f07fc..27d217585 100644 --- a/module/src/items/economy.rs +++ b/module/src/items/economy.rs @@ -182,7 +182,7 @@ pub(crate) fn apply_buy_bank_slot( let owned = player.bank_bag_slots; player.player_bytes_2 = lyracore_shared::packing::with_bank_bag_slots(player.player_bytes_2, owned); - ctx.db.game_world_entity().guid().update(player); + player.update(ctx); BuyBankSlotOutcome::Bought { price, owned } } @@ -218,7 +218,6 @@ pub(crate) fn apply_item_sell( "target is not a vendor", "sell", )?; - let entities = ctx.db.game_world_entity(); let instances = ctx.db.game_item_instance(); let inst = item_in_slot(ctx, player_guid, slot).ok_or_else(|| format!("no item in slot {slot}"))?; @@ -244,7 +243,7 @@ pub(crate) fn apply_item_sell( } let earned = sell_value(tmpl.sell_price, inst.stack_count); player.money = player.money.saturating_add(earned); - entities.guid().update(player); + player.update(ctx); let buyback_tab = ctx.db.game_character_buyback(); let mut old: Vec = buyback_tab.by_player_guid().filter(&player_guid).collect(); old.sort_by_key(|e| e.id); @@ -279,7 +278,6 @@ pub(crate) fn apply_buyback_item( "target is not a vendor", "use buyback", )?; - let entities = ctx.db.game_world_entity(); let buyback_tab = ctx.db.game_character_buyback(); let entries: Vec = buyback_tab.by_player_guid().filter(&player_guid).collect(); let ordered_ids = buyback_newest_first(entries.iter().map(|e| e.id).collect()); @@ -299,7 +297,7 @@ pub(crate) fn apply_buyback_item( } let owner_identity = player.owner_identity; player.money -= entry.price; - entities.guid().update(player); + player.update(ctx); let tmpl = ctx .db .game_item_template() @@ -364,7 +362,7 @@ pub(crate) fn apply_buy_item( // Propagate storage failure so the reducer rolls back the debit. player.money = player.money.saturating_sub(cost); let owner_identity = player.owner_identity; - ctx.db.game_world_entity().guid().update(player); + player.update(ctx); store_item(ctx, player_guid, owner_identity, &tmpl, count, false, None) } @@ -407,7 +405,6 @@ pub(crate) fn apply_player_repair( "target cannot repair", "repair", )?; - let entities = ctx.db.game_world_entity(); let instances = ctx.db.game_item_instance(); let templates = ctx.db.game_item_template(); @@ -445,7 +442,7 @@ pub(crate) fn apply_player_repair( } // Debit and repair must commit in the same transaction. player.money -= total_cost; - entities.guid().update(player); + player.update(ctx); for (mut inst, max_dur) in to_repair { inst.durability = max_dur; instances.guid().update(inst); diff --git a/module/src/loot/mod.rs b/module/src/loot/mod.rs index b19783704..acde0607b 100644 --- a/module/src/loot/mod.rs +++ b/module/src/loot/mod.rs @@ -656,7 +656,7 @@ pub(crate) fn apply_loot_money( let amount = corpse.money; corpse.money = 0; - entities.guid().update(corpse); + corpse.update(ctx); let recipients = corpse_eligible_recipients(ctx, target_guid); let grouped = money_is_grouped(&recipients); @@ -673,7 +673,7 @@ pub(crate) fn apply_loot_money( credit_purse(ctx, recipient_guid, share); } } - entities.guid().update(looter); + looter.update(ctx); if grouped { for &(recipient_guid, share) in &shares { @@ -699,7 +699,7 @@ fn credit_purse(ctx: &ReducerContext, recipient_guid: u64, share: u32) { let entities = ctx.db.game_world_entity(); if let Some(mut e) = entities.guid().find(recipient_guid) { e.money = e.money.saturating_add(share); - entities.guid().update(e); + e.update(ctx); return; } crate::transfer::defer_money_delta(ctx, recipient_guid, share); @@ -759,7 +759,7 @@ pub(crate) fn refresh_lootable(ctx: &ReducerContext, corpse_guid: u64) { }; if corpse.dynamic_flags & flag != want { corpse.dynamic_flags = (corpse.dynamic_flags & !flag) | want; - entities.guid().update(corpse); + corpse.update(ctx); } } diff --git a/module/src/loot/tag.rs b/module/src/loot/tag.rs index 7b75b4446..7154b81f0 100644 --- a/module/src/loot/tag.rs +++ b/module/src/loot/tag.rs @@ -127,7 +127,7 @@ pub(crate) fn record_first_threat( & !lyracore_shared::constants::unit_dynamic_flags::TAPPED_BY_PLAYER; if creature.dynamic_flags != stored { creature.dynamic_flags = stored; - entities.guid().update(creature); + creature.update(ctx); } true } @@ -346,7 +346,7 @@ pub(crate) fn clear(ctx: &ReducerContext, creature_guid: u64) { | lyracore_shared::constants::unit_dynamic_flags::TAPPED_BY_PLAYER; if creature.dynamic_flags & tag_flags != 0 { creature.dynamic_flags &= !tag_flags; - entities.guid().update(creature); + creature.update(ctx); } } } @@ -427,7 +427,7 @@ pub fn debug_verify_loot_tag_fixture(ctx: &ReducerContext) -> Result<(), String> .ok_or_else(|| "tag-less fixture creature is missing".to_string())?; tagless_live.money = 17; tagless_live.dynamic_flags |= lyracore_shared::constants::unit_dynamic_flags::LOOTABLE; - ctx.db.game_world_entity().guid().update(tagless_live); + tagless_live.update(ctx); ctx.db.game_corpse_loot().insert(super::CorpseLoot { id: 0, corpse_guid: tagless, @@ -801,7 +801,7 @@ fn insert_fixture_corpse( corpse.dead = true; corpse.health = 0; corpse.money = money; - entities.guid().update(corpse); + corpse.update(ctx); } #[cfg(feature = "debug_reducers")] @@ -881,7 +881,7 @@ fn insert_fixture_entity( entity.faction_template = 1; entity.next_level_xp = crate::xp::xp_to_next_level(1); } - ctx.db.game_world_entity().insert(entity); + entity.insert(ctx); } #[cfg(feature = "debug_reducers")] @@ -896,7 +896,7 @@ fn move_fixture_entity(ctx: &ReducerContext, guid: u64, x: f32) -> Result<(), St entity.grid_x = grid_x; entity.grid_y = grid_y; entity.cell = lyracore_shared::spatial::grid_cell_id(grid_x, grid_y); - entities.guid().update(entity); + entity.update(ctx); Ok(()) } diff --git a/module/src/mail.rs b/module/src/mail.rs index 914fe58f3..8f8fcacd0 100644 --- a/module/src/mail.rs +++ b/module/src/mail.rs @@ -3,7 +3,7 @@ use spacetimedb::{reducer, table, ReducerContext, Table, Timestamp}; use crate::game_item_instance; use crate::game_item_template; -use crate::game_world_entity; + use crate::items::ItemSnapshot; #[table(accessor = game_mail, public, index(accessor = by_recipient, btree(columns = [recipient_guid])))] pub struct Mail { @@ -310,7 +310,7 @@ pub(crate) fn debit_purse( return Err(unaffordable.to_string()); } payer.money -= copper; - ctx.db.game_world_entity().guid().update(payer); + payer.update(ctx); Ok(()) } #[allow(clippy::too_many_arguments)] @@ -423,7 +423,7 @@ pub(crate) fn apply_take_money( let mut taker = crate::helpers::acting_entity_by_guid(ctx, recipient_guid) .ok_or_else(|| lyracore_shared::mail::NOT_IN_WORLD.to_string())?; taker.money = credited(taker.money, money); - ctx.db.game_world_entity().guid().update(taker); + taker.update(ctx); clear_mail_money(ctx, mail_id); Ok(()) } diff --git a/module/src/mail_escrow.rs b/module/src/mail_escrow.rs index a36d9f00c..58d3c7d98 100644 --- a/module/src/mail_escrow.rs +++ b/module/src/mail_escrow.rs @@ -2,7 +2,6 @@ use spacetimedb::{log, reducer, table, ReducerContext, ScheduleAt, Table, TimeDu use lyracore_shared::mail::RewardHeader; -use crate::game_world_entity; use crate::helpers::require_operator; use crate::mail::game_mail; @@ -340,7 +339,7 @@ impl FenceSink for CtxDb<'_> { fn debit_purse(&mut self, sender_guid: u64, amount: u32) { if let Some(mut e) = crate::helpers::acting_entity_by_guid(self.ctx, sender_guid) { e.money = e.money.saturating_sub(amount); - self.ctx.db.game_world_entity().guid().update(e); + e.update(self.ctx); } } fn detach_item( @@ -376,7 +375,7 @@ impl PayoutSink for CtxDb<'_> { return false; }; e.money = crate::mail::credited(e.money, amount); - self.ctx.db.game_world_entity().guid().update(e); + e.update(self.ctx); true } fn grant_item( diff --git a/module/src/mail_reward.rs b/module/src/mail_reward.rs index 07b10facd..d276b0ba2 100644 --- a/module/src/mail_reward.rs +++ b/module/src/mail_reward.rs @@ -143,7 +143,7 @@ mod fixture { giver.grid_x = grid_x; giver.grid_y = grid_y; giver.cell = lyracore_shared::spatial::grid_cell_id(grid_x, grid_y); - entities.insert(giver); + giver.insert(ctx); let templates = ctx.db.game_mail_template(); let loot = ctx.db.game_mail_loot(); diff --git a/module/src/mount.rs b/module/src/mount.rs index fb6b1ea59..ce5fac728 100644 --- a/module/src/mount.rs +++ b/module/src/mount.rs @@ -52,7 +52,7 @@ pub(crate) fn recompute_mount(ctx: &ReducerContext, guid: u64) { } e.mount_display_id = display; e.run_speed_mult_bp = speed_bp; - entities.guid().update(e); + e.update(ctx); } pub(crate) fn dismount(ctx: &ReducerContext, guid: u64) { diff --git a/module/src/professions.rs b/module/src/professions.rs index dbbaf6ee0..1cc6d7972 100644 --- a/module/src/professions.rs +++ b/module/src/professions.rs @@ -136,7 +136,7 @@ pub(crate) fn skin_corpse( crate::skill::gain_profession_skill(ctx, looter_guid, crate::skill::skill_line::SKINNING, 1, 0); corpse.skinned = true; - entities.guid().update(corpse); + corpse.update(ctx); Ok(()) } diff --git a/module/src/quest.rs b/module/src/quest.rs index 2781d73a6..5911837b6 100644 --- a/module/src/quest.rs +++ b/module/src/quest.rs @@ -837,7 +837,7 @@ pub(crate) fn request_turn_in_quest( }); crate::xp::grant_xp(ctx, &mut player, xp); } - entities.guid().update(player); + player.update(ctx); crate::reputation::grant_reputation( ctx, @@ -918,7 +918,7 @@ pub fn debug_stage_choice_reward_fixture( giver.grid_x = grid_x; giver.grid_y = grid_y; giver.cell = lyracore_shared::spatial::grid_cell_id(grid_x, grid_y); - entities.insert(giver); + giver.insert(ctx); let templates = ctx.db.game_item_template(); for entry in [ @@ -1049,7 +1049,7 @@ pub fn debug_stage_choice_reward_fixture( player.next_level_xp = crate::xp::xp_to_next_level(1); player.money = 0; let owner_identity = player.owner_identity; - entities.guid().update(player); + player.update(ctx); crate::items::grant_item(ctx, CHOICE_FIXTURE_PLAYER, CHOICE_FIXTURE_OBJECTIVE_ITEM, 8)?; if fill_inventory { crate::items::grant_item(ctx, CHOICE_FIXTURE_PLAYER, CHOICE_FIXTURE_FILLER, 15)?; @@ -1311,14 +1311,14 @@ pub fn debug_verify_eventai_quest_credit_fixture(ctx: &ReducerContext) -> Result player.grid_y = grid_y; player.cell = lyracore_shared::spatial::grid_cell_id(grid_x, grid_y); let fixture_owner = player.owner_identity; - entities.guid().update(player); + player.update(ctx); let mut member = entities .guid() .find(EVENTAI_CREDIT_FIXTURE_PLAYER) .ok_or_else(|| "EventAI credit fixture player disappeared".to_string())?; member.guid = EVENTAI_CREDIT_FIXTURE_MEMBER; - entities.insert(member); + member.insert(ctx); let mut distant_member = entities .guid() .find(EVENTAI_CREDIT_FIXTURE_PLAYER) @@ -1329,13 +1329,13 @@ pub fn debug_verify_eventai_quest_credit_fixture(ctx: &ReducerContext) -> Result distant_member.grid_x = grid_x; distant_member.grid_y = grid_y; distant_member.cell = lyracore_shared::spatial::grid_cell_id(grid_x, grid_y); - entities.insert(distant_member); + distant_member.insert(ctx); let mut beneficiary = entities .guid() .find(EVENTAI_CREDIT_FIXTURE_PLAYER) .ok_or_else(|| "EventAI credit fixture player disappeared".to_string())?; beneficiary.guid = EVENTAI_CREDIT_FIXTURE_BENEFICIARY; - entities.insert(beneficiary); + beneficiary.insert(ctx); let groups = ctx.db.game_group(); groups.group_id().delete(EVENTAI_CREDIT_FIXTURE_GROUP); @@ -1590,7 +1590,7 @@ pub fn debug_verify_eventai_quest_credit_fixture(ctx: &ReducerContext) -> Result tapped_player.grid_x = grid_x; tapped_player.grid_y = grid_y; tapped_player.cell = lyracore_shared::spatial::grid_cell_id(grid_x, grid_y); - entities.guid().update(tapped_player); + tapped_player.update(ctx); if apply_eventai_credit(ctx, kill_request, &fallback_context) != QuestCreditOutcome::Refused || eventai_credit_fixture_count( ctx, @@ -1776,7 +1776,7 @@ pub fn debug_verify_raid_quest_credit_fixture(ctx: &ReducerContext) -> Result<() .find(RQC_PLAYER) .ok_or_else(|| "raid quest credit fixture player disappeared".to_string())?; clone.guid = guid; - entities.insert(clone); + clone.insert(ctx); } groups.insert(crate::Group { diff --git a/module/src/rest.rs b/module/src/rest.rs index 4b979b5fd..0f7b4e94f 100644 --- a/module/src/rest.rs +++ b/module/src/rest.rs @@ -1,8 +1,6 @@ use spacetimedb::{table, ReducerContext, Table, Timestamp}; use crate::game_character; -#[cfg(feature = "debug_reducers")] -use crate::game_world_entity; use crate::world::WorldEntity; use lyracore_shared::packing; @@ -109,7 +107,7 @@ pub fn debug_check_rest_at( e.x = x; e.y = y; check_rest_state(ctx, &mut e); - ctx.db.game_world_entity().guid().update(e); + e.update(ctx); Ok(()) } diff --git a/module/src/runtime_script.rs b/module/src/runtime_script.rs index e9e314420..1245ccc10 100644 --- a/module/src/runtime_script.rs +++ b/module/src/runtime_script.rs @@ -805,13 +805,12 @@ pub(crate) struct CoreEffects<'a> { impl EffectSink for CoreEffects<'_> { fn grant_xp(&mut self, character_guid: u64, amount: u32) { - use crate::game_world_entity; let Some(mut entity) = crate::helpers::acting_entity_by_guid(self.ctx, character_guid) else { return; }; crate::xp::grant_xp(self.ctx, &mut entity, amount); - self.ctx.db.game_world_entity().guid().update(entity); + entity.update(self.ctx); } fn heal(&mut self, healer_guid: u64, target_guid: u64, amount: u32) { diff --git a/module/src/spell/cast/resolve.rs b/module/src/spell/cast/resolve.rs index e6877da27..306ab39e9 100644 --- a/module/src/spell/cast/resolve.rs +++ b/module/src/spell/cast/resolve.rs @@ -86,7 +86,7 @@ pub(crate) fn resolve_cast_at_typed( if hdr.power_type == lyracore_shared::packing::power_type::MANA { caster.mana_regen_paused_until_ms = now_ms + 5000; } - ctx.db.game_world_entity().guid().update(caster); + caster.update(ctx); } // Dismount after charging power so the stale caster cannot overwrite the Mount Projection. diff --git a/module/src/spell/cast/targeting.rs b/module/src/spell/cast/targeting.rs index a3c5fa814..7e52daf82 100644 --- a/module/src/spell/cast/targeting.rs +++ b/module/src/spell/cast/targeting.rs @@ -911,7 +911,7 @@ fn apply_restore_effect( let before = t.health; t.health = t.max_health; let effective = t.health.saturating_sub(before); - ctx.db.game_world_entity().guid().update(t); + t.update(ctx); crate::threat::add_heal_threat(ctx, caster_guid, target_guid, effective); } EffectHit::none() @@ -923,7 +923,7 @@ fn apply_restore_effect( // vmangos `EnergizeBySpell` logs the full grant, not the gain. hit.energized = points.max(0) as u32; hit.power_type = t.power_type(); - ctx.db.game_world_entity().guid().update(t); + t.update(ctx); } if e.enters_combat { crate::combat::enter_combat(ctx, caster_guid); @@ -946,7 +946,7 @@ fn apply_restore_effect( return EffectHit::none(); } t.power -= drained; - ctx.db.game_world_entity().guid().update(t); + t.update(ctx); let damage = mana_burn_damage(drained, e.p1); let (dealt, absorbed) = apply_target_damage( ctx, @@ -1005,7 +1005,7 @@ fn apply_threat_effect( // Vanilla charges a queued strike when its swing fires, including a miss. if let Some(mut c) = ctx.db.game_world_entity().guid().find(caster_guid) { c.next_swing_spell = e.spell_id; - ctx.db.game_world_entity().guid().update(c); + c.update(ctx); } EffectHit::none() } @@ -1097,12 +1097,12 @@ fn apply_inventory_effect( .unwrap_or_else(|| pickpocket_copper(target.level)); if let Some(mut rogue) = entities.guid().find(caster_guid) { rogue.money = rogue.money.saturating_add(copper); - entities.guid().update(rogue); + rogue.update(ctx); } let target_entry = target.entry; target.pickpocketed = true; - entities.guid().update(target); + target.update(ctx); crate::loot::roll_pickpocket_loot(ctx, target_entry, target_guid); } } @@ -1228,12 +1228,12 @@ fn apply_character_effect( // Preserve byte 3, which carries the ghost visibility flag. let form_id = client_form_for_stance(new_stance) << 16; c.unit_bytes_1 = (c.unit_bytes_1 & 0xFF00_0000) | form_id; - ctx.db.game_world_entity().guid().update(c); + c.update(ctx); } else if form_recast_toggles_off(c.stance, new_stance) { // Druid caster form uses zero; client_form_for_stance(0) is Battle Stance. c.stance = 0; c.unit_bytes_1 &= 0xFF00_0000; - ctx.db.game_world_entity().guid().update(c); + c.update(ctx); } } EffectHit::none() @@ -1264,7 +1264,7 @@ pub(crate) fn apply_direct_heal( let before = t.health; t.health = healed_value(t.health, t.max_health, basis); let effective = t.health.saturating_sub(before); - ctx.db.game_world_entity().guid().update(t); + t.update(ctx); crate::threat::add_heal_threat(ctx, healer_guid, target_guid, effective); effective } else { @@ -1278,7 +1278,7 @@ fn convert_health_to_power(ctx: &ReducerContext, caster_guid: u64, amount: i32) let drain = amount.min(c.health.saturating_sub(1)); c.health -= drain; c.power = energized_value(c.power, c.max_power, drain as i32); - ctx.db.game_world_entity().guid().update(c); + c.update(ctx); } } @@ -1444,7 +1444,7 @@ fn charge_to_target(ctx: &ReducerContext, caster_guid: u64, target_guid: u64) { if dist < 0.5 { if let Some(mut c) = entities.guid().find(caster_guid) { c.orientation = facing; - entities.guid().update(c); + c.update(ctx); } return; } @@ -1474,7 +1474,7 @@ fn charge_to_target(ctx: &ReducerContext, caster_guid: u64, target_guid: u64) { c.cell = lyracore_shared::spatial::grid_cell_id(gx, gy); c.orientation = facing; c.last_move_ms = now_ms; - entities.guid().update(c); + c.update(ctx); } } diff --git a/module/src/spell/effects.rs b/module/src/spell/effects.rs index 156183613..f2a43b1fd 100644 --- a/module/src/spell/effects.rs +++ b/module/src/spell/effects.rs @@ -173,7 +173,7 @@ pub(crate) fn recompute_vitals(ctx: &ReducerContext, unit_guid: u64) { e.max_power = new_max_power; e.power = e.power.min(new_max_power); e.spirit = new_spirit; - entities.guid().update(e); + e.update(ctx); } pub(crate) fn recompute_sheet(ctx: &ReducerContext, unit_guid: u64) { @@ -240,7 +240,7 @@ pub(crate) fn recompute_sheet(ctx: &ReducerContext, unit_guid: u64) { e.sheet_ranged_ap = ranged_ap; e.sheet_ranged_dmg_min = ranged_dmg_min; e.sheet_ranged_dmg_max = ranged_dmg_max; - entities.guid().update(e); + e.update(ctx); } pub(crate) fn absorb_incoming(ctx: &ReducerContext, target_guid: u64, incoming: u32) -> u32 { diff --git a/module/src/spell/presentation.rs b/module/src/spell/presentation.rs index 9b51669f8..bedf113b1 100644 --- a/module/src/spell/presentation.rs +++ b/module/src/spell/presentation.rs @@ -22,6 +22,6 @@ pub(crate) fn recompute_display(ctx: &ReducerContext, guid: u64) { .unwrap_or(entity.native_display_id); if entity.display_id != display { entity.display_id = display; - ctx.db.game_world_entity().guid().update(entity); + entity.update(ctx); } } diff --git a/module/src/spell/scheduler.rs b/module/src/spell/scheduler.rs index e521816ad..2496017f6 100644 --- a/module/src/spell/scheduler.rs +++ b/module/src/spell/scheduler.rs @@ -179,7 +179,7 @@ pub(crate) fn do_resurrect_response( let pct = req.points.clamp(1, 100) as u32; t.health = revive_amount(t.max_health, req.points); t.power = t.max_power.saturating_mul(pct) / 100; - ctx.db.game_world_entity().guid().update(t); + t.update(ctx); crate::ghost::finish_resurrection(ctx, target_guid); } } @@ -196,7 +196,7 @@ pub(crate) fn do_cancel_cast( if caster.next_swing_spell != 0 { let mut c = caster; c.next_swing_spell = 0; - ctx.db.game_world_entity().guid().update(c); + c.update(ctx); } Ok(()) } @@ -562,7 +562,7 @@ fn flush_pool( for (&guid, &value) in pool { if let Some(mut target) = entities.guid().find(guid) { set(&mut target, value); - entities.guid().update(target); + target.update(ctx); } } } diff --git a/module/src/spell/self_resurrect.rs b/module/src/spell/self_resurrect.rs index 391881df7..29d20f19e 100644 --- a/module/src/spell/self_resurrect.rs +++ b/module/src/spell/self_resurrect.rs @@ -55,7 +55,7 @@ pub(crate) fn do_self_resurrect(ctx: &ReducerContext, character_guid: u64) -> Re ); character.health = health; character.power = power; - entities.guid().update(character); + character.update(ctx); crate::ghost::finish_resurrection(ctx, character_guid); Ok(()) } diff --git a/module/src/stats.rs b/module/src/stats.rs index 917eb94fc..9865114fc 100644 --- a/module/src/stats.rs +++ b/module/src/stats.rs @@ -179,7 +179,7 @@ pub(crate) fn set_character_level( apply_level_stats(ctx, &mut e, race, class, level); e.health = e.max_health; e.power = e.max_power; - entities.guid().update(e); + e.update(ctx); crate::spell::recompute_sheet(ctx, character_guid); } diff --git a/module/src/talent.rs b/module/src/talent.rs index 8e55722fa..20f6f1539 100644 --- a/module/src/talent.rs +++ b/module/src/talent.rs @@ -549,7 +549,7 @@ pub(crate) fn do_reset_talents( } entity.money = entity.money.saturating_sub(cost); - entities.guid().update(entity); + entity.update(ctx); character.respec_count += 1; chars.guid().update(character); Ok(cost) diff --git a/module/src/taxi.rs b/module/src/taxi.rs index 00701d934..edce76af1 100644 --- a/module/src/taxi.rs +++ b/module/src/taxi.rs @@ -625,7 +625,7 @@ impl FlightEndSink for CtxFlightEndSink<'_> { player.mount_display_id = cleared.mount_display_id; player.unit_flags = cleared.unit_flags; player.movement_flags = cleared.movement_flags; - self.ctx.db.game_world_entity().guid().update(player); + player.update(self.ctx); } fn project_character( @@ -1000,7 +1000,7 @@ pub fn gw_activate_taxi( }; ctx.db.game_active_taxi_flight().insert(flight); player.money = player_state.money; - ctx.db.game_world_entity().guid().update(player); + player.update(ctx); Ok(()) })(); @@ -1062,7 +1062,7 @@ pub(crate) fn arm_taxi_flight(ctx: &ReducerContext, character_guid: u64) { .game_active_taxi_flight() .character_guid() .update(flight); - ctx.db.game_world_entity().guid().update(player); + player.update(ctx); write_passenger_spline(ctx, spline); ctx.db .game_taxi_flight_schedule() @@ -1150,7 +1150,7 @@ pub fn advance_taxi_flight(ctx: &ReducerContext, schedule: TaxiFlightSchedule) { flight.current_node_index = position.point_index as u32; let spline = should_refresh_spline(previous_cell, (grid_x, grid_y), crossed_point) .then(|| remaining_spline(&flight, &player, &points, position, elapsed)); - ctx.db.game_world_entity().guid().update(player); + player.update(ctx); ctx.db .game_active_taxi_flight() .character_guid() diff --git a/module/src/trade.rs b/module/src/trade.rs index 3216c9659..1aae05557 100644 --- a/module/src/trade.rs +++ b/module/src/trade.rs @@ -705,7 +705,7 @@ fn run_trade_commit( partner_side.gold_offered, ) .ok_or("trade commit: gold arithmetic refused")?; - entities.guid().update(acceptor_row); + acceptor_row.update(ctx); let mut partner_row = entities .guid() .find(partner.guid) @@ -716,7 +716,7 @@ fn run_trade_commit( acceptor_side.gold_offered, ) .ok_or("trade commit: gold arithmetic refused")?; - entities.guid().update(partner_row); + partner_row.update(ctx); remove_session(ctx, &session); push_trade_event(ctx, acceptor.guid, event_kind::TRADE_COMPLETE, partner.guid); diff --git a/module/src/trainer.rs b/module/src/trainer.rs index e220ce1c8..8965c1a82 100644 --- a/module/src/trainer.rs +++ b/module/src/trainer.rs @@ -478,7 +478,7 @@ pub(crate) fn apply_trainer_buy( Ok(()) => { caster.money = caster.money.saturating_sub(offered.cost); let owner = caster.owner_identity; - entities.guid().update(caster); + caster.update(ctx); match grant { BuyGrant::Profession(line, cap) => { crate::skill::learn_profession(ctx, caster_guid, owner, line, cap as u16) diff --git a/module/src/tripwires.rs b/module/src/tripwires.rs index 089c0e94f..8e8b59230 100644 --- a/module/src/tripwires.rs +++ b/module/src/tripwires.rs @@ -801,6 +801,62 @@ mod build_scan_strip_tripwire { } } +#[cfg(test)] +mod entity_simulation_write_discipline { + fn raw_writes(source: &str) -> Vec<(usize, &'static str)> { + let source = source.replace("self.ctx", "ctx"); + crate::test_scan::raw_table_reads(&source, &["game_world_entity"], |source, offset| { + let tail: String = source[offset..] + .chars() + .take(96) + .filter(|c| !c.is_whitespace()) + .collect(); + tail.starts_with(".insert(") + || tail.starts_with(".try_insert(") + || tail.starts_with(".guid().update(") + }) + } + + #[test] + fn the_scan_detects_entity_writes_and_ignores_comments_and_test_code() { + let source = r#" + fn f(ctx: &ReducerContext) { + ctx.db.game_world_entity().insert(row); + let entities = self.ctx.db.game_world_entity(); + entities.guid().update(row); + // entities.insert(row); + row.update(ctx); + } + #[cfg(test)] + fn fixture(ctx: &ReducerContext) { + ctx.db.game_world_entity().insert(row); + } + "#; + assert_eq!(raw_writes(source).len(), 2); + } + + #[test] + fn entity_writes_stamp_the_simulation_predicates() { + let root = crate::test_scan::repo_root(); + let mut violations = Vec::new(); + for file in super::character_owned_tripwire::scanned_files() { + let relative = file.strip_prefix(&root).unwrap_or(&file); + if relative == std::path::Path::new("module/src/world/simulation.rs") { + continue; + } + let source = std::fs::read_to_string(&file).expect("readable Module source"); + for (line, _) in raw_writes(&source) { + violations.push(format!("{}:{line}", relative.display())); + } + } + assert!( + violations.is_empty(), + "entity writes must use WorldEntity::insert/update: {}", + violations.join(", ") + ); + } +} + #[cfg(test)] mod partition_discipline_tripwire { use crate::test_scan::{line_of, on_comment_line, repo_root}; @@ -824,8 +880,6 @@ mod partition_discipline_tripwire { ("module/src/encounter.rs", 2, "guid high-water marks over spawn/gameobject TEMPLATE rows when allocating a wave guid, not live-world reads"), ("module/src/creatures/pet.rs", 1, "`nearest_hostile_near` — KNOWN DEBT, a textbook `entities_near` radius search kept only because pets are rare (one per online warlock)"), ("packages/deadmines/src/choreography.rs", 1, "single instance-scoped boss-liveness check inside one encounter"), - ("module/src/creatures/tick/mod.rs", 3, "Active-cell classification snapshots all players. Diagnostic counters measure total rows and narrowed pass candidates."), - ("module/src/creatures/cycle/ctx.rs", 2, "Aggro snapshots all players and regeneration selects entities with health or power. Neither predicate has its own index."), ("module/src/spell/cast/targeting.rs", 1, "AoE/chain target resolution by full-iter + squared distance; a textbook `helpers::entities_near` call (perf-catalog Tier 1)"), ]; diff --git a/module/src/vmap.rs b/module/src/vmap.rs index c26294636..74f2f040e 100644 --- a/module/src/vmap.rs +++ b/module/src/vmap.rs @@ -2,6 +2,7 @@ use crate::terrain::game_terrain_chunk; use lyracore_shared::terrain::cell_key; use lyracore_shared::vmap::{decode, RayFlavor, VmapTri}; use spacetimedb::{reducer, table, ReducerContext, Table}; +use std::{cell::RefCell, collections::BTreeMap, rc::Rc}; const STAGING: u8 = 0; const VERIFIED: u8 = 1; @@ -10,6 +11,60 @@ const DISCARDED: u8 = 3; const MANIFEST_DOMAIN: &[u8] = b"lyracore-vmap-manifest-v1"; const COVERAGE_MANIFEST_DOMAIN: &[u8] = b"lyracore-vmap-nav-coverage-manifest-v1"; +const QUERY_CACHE_BYTES: usize = 4 * 1024 * 1024; +const QUERY_CACHE_CELLS: usize = 128; + +#[derive(Default)] +struct QueryCache { + cells: BTreeMap<(u64, u64), Option>>, + bytes: usize, +} + +impl QueryCache { + fn cell( + &mut self, + generation_id: u64, + key: u64, + load: impl FnOnce() -> Option>, + ) -> Option { + if let Some(cached) = self.cells.get(&(generation_id, key)) { + return cached.clone().map(CellTriangles::Shared); + } + let triangles = load(); + let bytes = triangles + .as_ref() + .map_or(0, |rows| std::mem::size_of_val(rows.as_slice())); + if bytes > QUERY_CACHE_BYTES { + return triangles.map(CellTriangles::Owned); + } + if self.cells.len() == QUERY_CACHE_CELLS || self.bytes + bytes > QUERY_CACHE_BYTES { + self.cells.clear(); + self.bytes = 0; + } + let triangles = triangles.map(Rc::<[VmapTri]>::from); + self.bytes += bytes; + self.cells.insert((generation_id, key), triangles.clone()); + triangles.map(CellTriangles::Shared) + } +} + +thread_local! { + static QUERY_CACHE: RefCell> = const { RefCell::new(None) }; +} + +// Static cells are immutable after generation verification; dynamic colliders bypass this cache. +pub(crate) fn with_query_cache(query: impl FnOnce() -> T) -> T { + struct RestoreCache(Option); + impl Drop for RestoreCache { + fn drop(&mut self) { + QUERY_CACHE.with(|cache| *cache.borrow_mut() = self.0.take()); + } + } + let _restore = + RestoreCache(QUERY_CACHE.with(|cache| cache.replace(Some(QueryCache::default())))); + query() +} + fn require_staging(state: u8) -> Result<(), String> { (state == STAGING) .then_some(()) @@ -674,25 +729,48 @@ pub(crate) fn rays_enabled(ctx: &ReducerContext) -> bool { .is_some_and(|c| c.vmap_enabled) } -fn fetcher(ctx: &ReducerContext, map_id: u32) -> impl FnMut(u16, u16) -> Option> + '_ { +enum CellTriangles { + Owned(Vec), + Shared(Rc<[VmapTri]>), +} + +impl AsRef<[VmapTri]> for CellTriangles { + fn as_ref(&self) -> &[VmapTri] { + match self { + Self::Owned(triangles) => triangles, + Self::Shared(triangles) => triangles, + } + } +} + +fn fetcher( + ctx: &ReducerContext, + map_id: u32, +) -> impl FnMut(u16, u16) -> Option + '_ { let generation_id = active_generation_id(ctx, map_id); move |cx, cy| { let generation_id = generation_id?; let key = cell_key(map_id, cx, cy); - let mut tris = Vec::new(); - let mut any = false; - for row in ctx - .db - .game_vmap_generation_chunk() - .by_generation_cell() - .filter((generation_id, key)) - { - any = true; - if let Ok(mut t) = decode(&row.blob) { - tris.append(&mut t); + let load = || { + let mut tris = Vec::new(); + let mut any = false; + for row in ctx + .db + .game_vmap_generation_chunk() + .by_generation_cell() + .filter((generation_id, key)) + { + any = true; + if let Ok(mut t) = decode(&row.blob) { + tris.append(&mut t); + } } - } - any.then_some(tris) + any.then_some(tris) + }; + QUERY_CACHE.with(|cache| match cache.borrow_mut().as_mut() { + Some(cache) => cache.cell(generation_id, key, load), + None => load().map(CellTriangles::Owned), + }) } } @@ -891,6 +969,109 @@ pub(crate) fn is_indoor(ctx: &ReducerContext, map_id: u32, x: f32, y: f32, z: f3 #[cfg(test)] mod tests { use super::*; + + #[test] + fn repeated_rays_decode_each_static_cell_once_and_generation_changes_reload_it() { + use lyracore_shared::vmap::{cast_ray, encode, TriClass}; + let wall = |x| { + vec![VmapTri { + verts: [[x, 1199.0, 49.0], [x, 1202.0, 49.0], [x, 1200.0, 52.0]], + class: TriClass::Wmo { + group_id: 1, + mogp_flags: 0, + }, + }] + }; + let mut cache = QueryCache::default(); + let mut decodes = 0; + let from = [1200.0, 1200.0, 50.0]; + let to = [1201.0, 1200.0, 50.0]; + let first_blob = encode(&wall(1200.25)); + let mut first_count = 0; + for query in 0..32 { + let hit = cast_ray( + &mut |cx, cy| { + cache.cell(7, cell_key(0, cx, cy), || { + decodes += 1; + Some(decode(&first_blob).unwrap()) + }) + }, + from, + to, + RayFlavor::Los, + ); + assert_eq!(hit, Some([1200.25, 1200.0, 50.0])); + if query == 0 { + first_count = decodes; + } + assert_eq!(decodes, first_count); + } + let next_blob = encode(&wall(1200.75)); + let next_hit = cast_ray( + &mut |cx, cy| { + cache.cell(8, cell_key(0, cx, cy), || { + decodes += 1; + Some(decode(&next_blob).unwrap()) + }) + }, + from, + to, + RayFlavor::Collision, + ); + assert_eq!(next_hit, Some([1200.75, 1200.0, 50.0])); + assert_eq!(decodes, first_count * 2); + } + + #[test] + fn query_cache_does_not_carry_static_cells_into_the_next_firing() { + with_query_cache(|| { + QUERY_CACHE.with(|cache| { + cache + .borrow_mut() + .as_mut() + .unwrap() + .cell(1, 2, || Some(Vec::new())); + }); + }); + with_query_cache(|| { + QUERY_CACHE.with(|cache| assert!(cache.borrow().as_ref().unwrap().cells.is_empty())); + }); + QUERY_CACHE.with(|cache| assert!(cache.borrow().is_none())); + } + + #[test] + fn cached_missing_cells_have_a_fixed_memory_bound() { + let mut cache = QueryCache::default(); + for key in 0..QUERY_CACHE_CELLS as u64 + 1 { + assert!(cache.cell(7, key, || None).is_none()); + assert!(cache.cells.len() <= QUERY_CACHE_CELLS); + } + assert_eq!(cache.cells.len(), 1); + assert_eq!(cache.bytes, 0); + } + + #[test] + fn oversized_cells_keep_the_decoded_allocation_and_leave_the_cache_unchanged() { + let mut cache = QueryCache::default(); + let triangle = VmapTri { + verts: [[0.0; 3]; 3], + class: lyracore_shared::vmap::TriClass::M2, + }; + let cached = cache.cell(7, 1, || Some(vec![triangle])).unwrap(); + let cached_pointer = cached.as_ref().as_ptr(); + let triangles = vec![triangle; QUERY_CACHE_BYTES / std::mem::size_of::() + 1]; + let decoded_pointer = triangles.as_ptr(); + + let oversized = cache.cell(7, 2, || Some(triangles)).unwrap(); + assert_eq!(oversized.as_ref().as_ptr(), decoded_pointer); + assert_eq!(cache.cells.len(), 1); + assert_eq!(cache.bytes, std::mem::size_of::()); + let retained = cache + .cell(7, 1, || panic!("the cached cell must not be loaded again")) + .unwrap(); + assert_eq!(retained.as_ref().as_ptr(), cached_pointer); + } + #[test] fn only_a_complete_staging_generation_can_be_verified() { assert!(require_staging(STAGING).is_ok()); diff --git a/module/src/world.rs b/module/src/world.rs index 7f629b048..7aacfe8f2 100644 --- a/module/src/world.rs +++ b/module/src/world.rs @@ -9,6 +9,9 @@ use crate::{ game_character, game_character_buyback, game_corpse, game_creature_spline, game_instance, }; +mod simulation; +pub(crate) use simulation::backfill_simulation; + #[table(accessor = game_world_state_name)] pub struct WorldStateName { #[primary_key] @@ -92,7 +95,11 @@ pub(crate) fn clear_relay_world_states_for_instance(ctx: &ReducerContext, instan index(accessor = by_cell, btree(columns = [map_id, instance_id, cell])), index(accessor = by_cell_entry, btree(columns = [map_id, instance_id, cell, entry])), index(accessor = by_owner, btree(columns = [owner_identity])), - index(accessor = by_entry, btree(columns = [entry])) + index(accessor = by_entry, btree(columns = [entry])), + index(accessor = by_owner_guid, btree(columns = [owner_guid])), + index(accessor = by_regeneration_pending, btree(columns = [regeneration_pending])), + index(accessor = by_combat_active, btree(columns = [combat_active])), + index(accessor = by_simulation_indexed, btree(columns = [simulation_indexed])) )] pub struct WorldEntity { #[primary_key] @@ -232,6 +239,12 @@ pub struct WorldEntity { pub sheet_ranged_dmg_min: u32, #[default(0)] pub sheet_ranged_dmg_max: u32, + #[default(false)] + pub regeneration_pending: bool, + #[default(false)] + pub combat_active: bool, + #[default(false)] + pub simulation_indexed: bool, } impl WorldEntity { @@ -462,7 +475,7 @@ pub(crate) fn teleport_player( if let Some(zone) = destination_zone { e.zone_id = zone; } - entities.guid().update(e); + e.update(ctx); } if !crate::mount::mount_cast_allowed_here(ctx, map_id, x, y, z) { @@ -666,7 +679,7 @@ pub(crate) fn apply_player_login( entity.unit_bytes_1 = unit_bytes_1; } crate::away::at_world_entry(ctx, &mut entity, entry); - entities.insert(entity); + entity.insert(ctx); crate::ghost::restore(ctx, character_guid); crate::spell::recompute_display(ctx, character_guid); crate::taxi::arm_taxi_flight(ctx, character_guid); @@ -879,8 +892,6 @@ pub(crate) fn apply_movement_update( spacetimedb::log::info!("MOVEMENT_ENTRIES total={n}"); } } - let entities = ctx.db.game_world_entity(); - if mover.dead && mover.player_flags & lyracore_shared::constants::player_flags::GHOST == 0 { return Ok(()); } @@ -982,7 +993,7 @@ pub(crate) fn apply_movement_update( ); if plan.persist_entity { - entities.guid().update(mover); + mover.update(ctx); } if plan.fall_lethal { crate::combat::kill_player(ctx, mover_guid, mover_guid); @@ -1027,7 +1038,7 @@ pub(crate) fn apply_set_target( target_guid: u64, ) -> Result<(), String> { player.target_guid = target_guid; - ctx.db.game_world_entity().guid().update(player); + player.update(ctx); Ok(()) } @@ -1122,7 +1133,7 @@ pub(crate) fn do_repop(ctx: &ReducerContext, guid: u64) -> Result<(), String> { let player_guid = player.guid; let (death_x, death_y, death_map_id, death_race) = (player.x, player.y, player.map_id, player.race()); - entities.guid().update(player); + player.update(ctx); crate::ghost::restore(ctx, player_guid); crate::creatures::despawn_pets(ctx, player_guid); @@ -1175,7 +1186,7 @@ pub(crate) fn apply_set_sheathed( return Ok(()); } actor.unit_bytes_2 = packed; - ctx.db.game_world_entity().guid().update(actor); + actor.update(ctx); Ok(()) } @@ -1198,7 +1209,7 @@ pub(crate) fn apply_set_stand_state( return Ok(()); } actor.unit_bytes_1 = packed; - ctx.db.game_world_entity().guid().update(actor); + actor.update(ctx); Ok(()) } @@ -1217,7 +1228,7 @@ pub(crate) fn stand_up_on_damage(ctx: &ReducerContext, target_guid: u64) { return; } target.unit_bytes_1 &= !0xFF; - entities.guid().update(target); + target.update(ctx); } pub const RESURRECTION_SICKNESS_SPELL: u32 = 15007; @@ -1240,7 +1251,7 @@ pub(crate) fn do_spirit_healer_res(ctx: &ReducerContext, guid: u64) -> Result<() let player_level = player.level; player.health = health; player.power = power; - entities.guid().update(player); + player.update(ctx); crate::ghost::finish_resurrection(ctx, guid); if player_level >= 11 { crate::spell::apply_spell_auras(ctx, RESURRECTION_SICKNESS_SPELL, guid, 1, 1); diff --git a/module/src/world/simulation.rs b/module/src/world/simulation.rs new file mode 100644 index 000000000..00c9958a4 --- /dev/null +++ b/module/src/world/simulation.rs @@ -0,0 +1,114 @@ +use spacetimedb::{ReducerContext, Table}; + +use super::{game_world_entity, WorldEntity}; + +impl WorldEntity { + fn stamp_simulation(&mut self) { + use lyracore_shared::packing::power_type; + + self.combat_active = + self.unit_flags & lyracore_shared::constants::unit_flags::IN_COMBAT != 0; + let power_pending = self.max_power > 0 + && match self.power_type() { + power_type::MANA | power_type::ENERGY => self.power != self.max_power, + power_type::RAGE => self.power > 0, + _ => false, + }; + self.regeneration_pending = !self.dead && (self.health < self.max_health || power_pending); + self.simulation_indexed = true; + } + + pub(crate) fn insert(mut self, ctx: &ReducerContext) -> Self { + self.stamp_simulation(); + ctx.db.game_world_entity().insert(self) + } + + pub(crate) fn update(mut self, ctx: &ReducerContext) -> Self { + self.stamp_simulation(); + ctx.db.game_world_entity().guid().update(self) + } +} + +const BACKFILL_BATCH_SIZE: usize = 512; + +// Scheduled phases can resume only after this returns true. +pub(crate) fn backfill_simulation(ctx: &ReducerContext) -> bool { + let entities = ctx.db.game_world_entity(); + let pending: Vec<_> = entities + .by_simulation_indexed() + .filter(false) + .take(BACKFILL_BATCH_SIZE) + .collect(); + for entity in pending { + entity.update(ctx); + } + entities + .by_simulation_indexed() + .filter(false) + .next() + .is_none() +} + +#[cfg(test)] +mod tests { + use super::*; + use lyracore_shared::packing::power_type; + + fn entity() -> WorldEntity { + let mut entity = crate::helpers::tests::entity(7, 0, 0, 0, 0); + entity.health = 100; + entity.max_health = 100; + entity.max_power = 100; + entity + } + + #[test] + fn regeneration_selects_deficits_and_rage_without_revisiting_full_pools() { + for (kind, power, pending) in [ + (power_type::MANA, 100, false), + (power_type::MANA, 99, true), + (power_type::MANA, 101, true), + (power_type::ENERGY, 100, false), + (power_type::ENERGY, 0, true), + (power_type::RAGE, 0, false), + (power_type::RAGE, 100, true), + ] { + let mut entity = entity(); + entity.unit_bytes_0 = u32::from(kind) << 24; + entity.power = power; + entity.stamp_simulation(); + assert_eq!(entity.regeneration_pending, pending, "{kind}, {power}"); + } + } + + #[test] + fn health_and_death_transitions_replace_the_regeneration_predicate() { + let mut entity = entity(); + entity.max_power = 0; + entity.health = 99; + entity.stamp_simulation(); + assert!(entity.regeneration_pending); + entity.dead = true; + entity.stamp_simulation(); + assert!(!entity.regeneration_pending); + entity.dead = false; + entity.health = 100; + entity.stamp_simulation(); + assert!(!entity.regeneration_pending); + } + + #[test] + fn migration_defaults_are_replaced_from_the_live_gameplay_fields() { + let mut entity = entity(); + entity.unit_flags = lyracore_shared::constants::unit_flags::IN_COMBAT; + entity.health = 10; + assert!(!entity.simulation_indexed); + entity.stamp_simulation(); + assert!(entity.simulation_indexed); + assert!(entity.regeneration_pending); + assert!(entity.combat_active); + entity.unit_flags = 0; + entity.stamp_simulation(); + assert!(!entity.combat_active); + } +} diff --git a/module/src/xp.rs b/module/src/xp.rs index 5c586819a..46483ed9b 100644 --- a/module/src/xp.rs +++ b/module/src/xp.rs @@ -262,7 +262,7 @@ pub(crate) fn award_xp( rested_bonus: award - base, }); grant_xp(ctx, &mut p, award); - entities.guid().update(p); + p.update(ctx); } pub(crate) fn grant_xp(ctx: &ReducerContext, p: &mut WorldEntity, amount: u32) { @@ -336,7 +336,7 @@ pub(crate) fn grant_xp(ctx: &ReducerContext, p: &mut WorldEntity, amount: u32) { let entities = ctx.db.game_world_entity(); if let Some(placeholder) = entities.guid().find(p.guid) { let moved = std::mem::replace(p, placeholder); - entities.guid().update(moved); + moved.update(ctx); crate::spell::recompute_sheet(ctx, p.guid); if let Some(fresh) = entities.guid().find(p.guid) { *p = fresh; diff --git a/module/tests/additive_upgrade.rs b/module/tests/additive_upgrade.rs new file mode 100644 index 000000000..637073950 --- /dev/null +++ b/module/tests/additive_upgrade.rs @@ -0,0 +1,194 @@ +mod support; + +use std::fs; + +use serde_json::Value; +use support::{ + actor, event_rows, fence_subscription, module_bytes, poll_until, subscribe_with_fence, Row, + Standalone, POLL_TIMEOUT, +}; + +const LEGACY_NOTIFICATIONS: &[&str] = &[ + "game_combat_event", + "game_spell_cast_event", + "game_spell_impact_event", + "game_chat_event", + "game_emote_event", +]; + +#[test] +#[ignore = "requires LYRACORE_TEST_BASELINE_WASM from 44b04878 and the SpacetimeDB 2.7.1 CLI"] +fn populated_old_module_upgrades_without_clearing() { + let baseline_path = std::env::var_os("LYRACORE_TEST_BASELINE_WASM") + .expect("set LYRACORE_TEST_BASELINE_WASM to the Module Wasm built at 44b04878f2050936faa61d6f087f3aa1a7a78a46 with debug_reducers,package_test_fixture"); + let baseline = fs::read(baseline_path).expect("read the baseline Module Wasm"); + assert!(baseline.starts_with(b"\0asm"), "baseline is not Wasm"); + let current = module_bytes(); + let mut shard = populated_old_module(&baseline); + let character_before = character(&shard); + let entity_before = entity(&shard); + for column in [ + "simulation_indexed", + "regeneration_pending", + "combat_active", + ] { + assert!( + !entity_before.contains_key(column), + "the baseline already has {column}" + ); + } + let legacy_emotes = shard.query_rows("SELECT * FROM game_emote_event WHERE sender_guid = 1"); + assert_eq!(legacy_emotes.len(), 1); + let old_schema = shard.describe(); + + shard.publish_module_bytes(current); + + assert_eq!(character(&shard), character_before); + let mut upgraded = entity(&shard); + for column in [ + "simulation_indexed", + "regeneration_pending", + "combat_active", + ] { + assert_eq!( + upgraded.remove(column).as_deref(), + Some("false"), + "{column}" + ); + } + assert_eq!( + upgraded, entity_before, + "old WorldEntity columns must survive" + ); + assert_eq!( + shard.query_rows("SELECT * FROM game_emote_event WHERE sender_guid = 1"), + legacy_emotes + ); + let new_schema = shard.describe(); + for name in LEGACY_NOTIFICATIONS { + assert_eq!( + table_schema(&new_schema, name), + table_schema(&old_schema, name), + "the published schema of {name} must stay intact" + ); + table(&new_schema, &format!("{name}_live")); + } + + shard.assert_call("debug_arm_instance_tick", &["0", "100"]); + assert!( + poll_until(POLL_TIMEOUT, || { + let row = entity(&shard); + row["simulation_indexed"] == "true" + && row["regeneration_pending"] == "true" + && row["combat_active"] == "true" + }), + "the scheduled backfill must derive simulation columns from preserved gameplay state" + ); + assert_eq!(character(&shard), character_before); + + let events = subscribe_with_fence( + &shard, + &[ + "SELECT * FROM game_emote_event_live WHERE sender_guid = 1", + "SELECT * FROM game_emote_event WHERE sender_guid = 1", + ], + ); + send_emote(&shard, 35); + fence_subscription(&shard, &events); + let emotes = event_rows(&events, "game_emote_event_live"); + assert_eq!(emotes.len(), 1); + assert_eq!(emotes[0]["sender_guid"], "1"); + assert_eq!(emotes[0]["text_emote"], "35"); + assert!(event_rows(&events, "game_emote_event").is_empty()); + assert!(shard + .query_rows("SELECT * FROM game_emote_event_live") + .is_empty()); + assert_eq!( + shard.query_rows("SELECT * FROM game_emote_event WHERE sender_guid = 1"), + legacy_emotes + ); +} + +fn populated_old_module(baseline: &[u8]) -> Standalone { + let mut shard = Standalone::start("additive-upgrade"); + shard.publish_module_bytes(baseline); + shard.assert_call("claim_operator", &[]); + shard.assert_call("debug_spawn_player_entity", &["1"]); + shard.assert_sql("DELETE FROM game_creature_move_schedule"); + shard.assert_sql("DELETE FROM game_melee_schedule"); + shard.assert_sql("DELETE FROM game_event_reaper_schedule"); + shard.assert_sql("UPDATE game_character SET money = 123456, xp = 37 WHERE guid = 1"); + let in_combat = lyracore_shared::constants::unit_flags::IN_COMBAT; + shard.assert_sql(&format!( + "UPDATE game_world_entity SET health = 17, max_health = 100, power = 0, max_power = 0, \ + unit_flags = {in_combat}, combat_until_ms = {} WHERE guid = 1", + u64::MAX + )); + send_emote(&shard, 34); + shard +} + +fn send_emote(shard: &Standalone, text_emote: u32) { + shard.assert_call( + "gw_send_emote", + &[&actor("1"), &text_emote.to_string(), "66", "0"], + ); +} + +fn character(shard: &Standalone) -> Row { + shard + .query_rows("SELECT * FROM game_character WHERE guid = 1") + .remove(0) +} + +fn entity(shard: &Standalone) -> Row { + shard + .query_rows("SELECT * FROM game_world_entity WHERE guid = 1") + .remove(0) +} + +fn table<'a>(schema: &'a Value, name: &str) -> &'a Value { + schema["tables"] + .as_array() + .expect("Module tables") + .iter() + .find(|table| table["name"] == name) + .unwrap_or_else(|| panic!("missing table {name}")) +} + +fn table_schema(schema: &Value, name: &str) -> Value { + let mut definition = table(schema, name).clone(); + let type_ref = definition + .as_object_mut() + .unwrap() + .remove("product_type_ref") + .expect("table row type") + .as_u64() + .expect("table row type reference") as usize; + let types = schema["typespace"]["types"].as_array().expect("typespace"); + definition["row_type"] = resolve_type(&types[type_ref], types); + definition +} + +fn resolve_type(value: &Value, types: &[Value]) -> Value { + match value { + Value::Object(fields) => { + if let Some(type_ref) = fields.get("Ref").and_then(Value::as_u64) { + return resolve_type(&types[type_ref as usize], types); + } + Value::Object( + fields + .iter() + .map(|(name, value)| (name.clone(), resolve_type(value, types))) + .collect(), + ) + } + Value::Array(values) => Value::Array( + values + .iter() + .map(|value| resolve_type(value, types)) + .collect(), + ), + value => value.clone(), + } +} diff --git a/module/tests/aura_tick.rs b/module/tests/aura_tick.rs index 43492438e..e1233f8a0 100644 --- a/module/tests/aura_tick.rs +++ b/module/tests/aura_tick.rs @@ -17,10 +17,10 @@ fn a_periodic_kill_commits_the_death_and_an_unrelated_heal() { standalone.assert_sql("DELETE FROM game_melee_schedule"); standalone.assert_call("debug_spawn_player_entity", &[&CHARACTER.to_string()]); standalone.assert_sql(&format!( - "UPDATE game_world_entity SET health = 1, max_health = 1 WHERE guid = {WOLF}" + "UPDATE game_world_entity SET simulation_indexed = false, health = 1, max_health = 1 WHERE guid = {WOLF}" )); standalone.assert_sql(&format!( - "UPDATE game_world_entity SET health = 50, max_health = 100 WHERE guid = {CHARACTER}" + "UPDATE game_world_entity SET simulation_indexed = false, health = 50, max_health = 100 WHERE guid = {CHARACTER}" )); assert_eq!(health(&standalone, WOLF), 1); standalone.assert_call( diff --git a/module/tests/binding_quests.rs b/module/tests/binding_quests.rs index 25e9b41c5..c0fc485a1 100644 --- a/module/tests/binding_quests.rs +++ b/module/tests/binding_quests.rs @@ -198,7 +198,7 @@ fn binding_items_require_the_circle_and_complete_both_quest_variants_without_rep node.assert_call("install_guid_range", &["0"]); node.assert_sql("UPDATE game_character SET level = 20, class = 9, race = 1 WHERE guid = 1"); node.assert_call("debug_spawn_player_entity", &[PLAYER]); - node.assert_sql("UPDATE game_world_entity SET level = 20, health = 100000, max_health = 100000 WHERE guid = 1"); + node.assert_sql("UPDATE game_world_entity SET simulation_indexed = false, level = 20, health = 100000, max_health = 100000 WHERE guid = 1"); copy_template( &node, "game_creature_template", @@ -344,7 +344,7 @@ fn binding_items_require_the_circle_and_complete_both_quest_variants_without_rep inventory ); node.assert_sql(&format!( - "UPDATE game_world_entity SET health = 1 WHERE guid = {}", + "UPDATE game_world_entity SET simulation_indexed = false, health = 1 WHERE guid = {}", summon["guid"] )); node.assert_call("gw_attack", &[&support::actor(PLAYER), &summon["guid"]]); diff --git a/module/tests/chat_channels.rs b/module/tests/chat_channels.rs index 6f07afb33..2b6e2d1ac 100644 --- a/module/tests/chat_channels.rs +++ b/module/tests/chat_channels.rs @@ -458,7 +458,9 @@ fn channel_speech_follows_the_members_flags_not_the_speakers_health() { op(&realm, &actor("2"), JOIN, "Raiders", "", HUMAN); realm.assert_call("debug_spawn_player_entity", &["1"]); - realm.assert_sql("UPDATE game_world_entity SET dead = true WHERE guid = 1"); + realm.assert_sql( + "UPDATE game_world_entity SET simulation_indexed = false, dead = true WHERE guid = 1", + ); let owner_line = inserted(&realm, LINES, "game_realm_chat_event", || { realm.assert_call( "realm_chat", diff --git a/module/tests/combat_log.rs b/module/tests/combat_log.rs index e9e2f202a..c6b882774 100644 --- a/module/tests/combat_log.rs +++ b/module/tests/combat_log.rs @@ -29,10 +29,10 @@ fn damage_heal_and_energize_ticks_each_log_their_outcome() { standalone.assert_sql("DELETE FROM game_melee_schedule"); standalone.assert_call("debug_spawn_player_entity", &[&CHARACTER.to_string()]); standalone.assert_sql(&format!( - "UPDATE game_world_entity SET health = 100, max_health = 100 WHERE guid = {WOLF}" + "UPDATE game_world_entity SET simulation_indexed = false, health = 100, max_health = 100 WHERE guid = {WOLF}" )); standalone.assert_sql(&format!( - "UPDATE game_world_entity SET health = 50, max_health = 100, power = 0, max_power = 100 \ + "UPDATE game_world_entity SET simulation_indexed = false, health = 50, max_health = 100, power = 0, max_power = 100 \ WHERE guid = {CHARACTER}" )); let character = standalone @@ -136,7 +136,7 @@ fn a_direct_energize_records_the_power_given_on_the_go() { (SELF_ENERGIZE as u64) << 2 )); standalone.assert_sql(&format!( - "UPDATE game_world_entity SET power = 0, max_power = 100 WHERE guid = {CHARACTER}" + "UPDATE game_world_entity SET simulation_indexed = false, power = 0, max_power = 100 WHERE guid = {CHARACTER}" )); let character = standalone .query_rows(&format!( @@ -206,7 +206,7 @@ fn a_resisted_spell_lands_none_of_its_effects_and_reports_the_resist() { "UPDATE game_world_entity SET level = 1 WHERE guid = {CHARACTER}" )); standalone.assert_sql(&format!( - "UPDATE game_world_entity SET level = 60, health = 100000, max_health = 100000 \ + "UPDATE game_world_entity SET simulation_indexed = false, level = 60, health = 100000, max_health = 100000 \ WHERE guid = {wolf}" )); standalone.assert_sql(&format!( diff --git a/module/tests/distract.rs b/module/tests/distract.rs index d90fcdee4..af623032e 100644 --- a/module/tests/distract.rs +++ b/module/tests/distract.rs @@ -221,7 +221,7 @@ fn assert_refused(output: &std::process::Output, reason: &str) { fn hold_in_combat(shard: &Standalone, guid: u64) { let flags: u32 = number(&entity(shard, guid), "unit_flags"); shard.assert_sql(&format!( - "UPDATE game_world_entity SET unit_flags = {}, combat_until_ms = {} WHERE guid = {guid}", + "UPDATE game_world_entity SET simulation_indexed = false, unit_flags = {}, combat_until_ms = {} WHERE guid = {guid}", flags | unit_flags::IN_COMBAT, u64::MAX, )); diff --git a/module/tests/duel_killing_blow.rs b/module/tests/duel_killing_blow.rs index d96f9662b..f86a9ad2e 100644 --- a/module/tests/duel_killing_blow.rs +++ b/module/tests/duel_killing_blow.rs @@ -41,7 +41,7 @@ fn a_duel_ending_swing_is_no_killing_blow() { number(&player, "z"), ); standalone.assert_sql(&format!( - "UPDATE game_world_entity SET x = {}, y = {y}, z = {z}, health = 1 WHERE guid = {rival}", + "UPDATE game_world_entity SET simulation_indexed = false, x = {}, y = {y}, z = {z}, health = 1 WHERE guid = {rival}", x + 1.0 )); standalone.assert_sql(&format!( diff --git a/module/tests/hunter_pet.rs b/module/tests/hunter_pet.rs index 6c2ad0ed7..017e15ef9 100644 --- a/module/tests/hunter_pet.rs +++ b/module/tests/hunter_pet.rs @@ -284,7 +284,7 @@ fn existing_live_pet_recovers_rename_eligibility_without_losing_other_flags() { let original_name = protocol_name(&shard); shard.assert_sql("UPDATE game_hunter_pet SET name_timestamp = 0 WHERE owner_guid = 1"); shard.assert_sql(&format!( - "UPDATE game_world_entity SET unit_flags = 512 WHERE guid = {pet}" + "UPDATE game_world_entity SET simulation_indexed = false, unit_flags = 512 WHERE guid = {pet}" )); assert!(poll_until(POLL_TIMEOUT, || live_pet_flags(&shard) & PET_RENAME_FLAG @@ -296,7 +296,7 @@ fn existing_live_pet_recovers_rename_eligibility_without_losing_other_flags() { let renamed_name = protocol_name(&shard); assert!(renamed_name.1 > original_name.1); shard.assert_sql(&format!( - "UPDATE game_world_entity SET unit_flags = 528 WHERE guid = {pet}" + "UPDATE game_world_entity SET simulation_indexed = false, unit_flags = 528 WHERE guid = {pet}" )); assert!(poll_until(POLL_TIMEOUT, || live_pet_flags(&shard) & PET_RENAME_FLAG diff --git a/module/tests/inventory_protocol.rs b/module/tests/inventory_protocol.rs index 470e4664f..1aae20bc6 100644 --- a/module/tests/inventory_protocol.rs +++ b/module/tests/inventory_protocol.rs @@ -32,7 +32,9 @@ fn inventory_changes_preserve_accepted_trade_items() { "UPDATE game_world_entity SET x = 100, y = 100, z = 100, money = 100 WHERE guid = {other}" )); shard.assert_sql("UPDATE game_world_entity SET money = 0 WHERE guid = 1"); - shard.assert_sql("UPDATE game_world_entity SET health = 1 WHERE guid = 1"); + shard.assert_sql( + "UPDATE game_world_entity SET simulation_indexed = false, health = 1 WHERE guid = 1", + ); shard.assert_call("debug_grant_item", &["1", "5090052", "15"]); shard.assert_call("debug_split_item", &["1", "23", "5", "24"]); shard.assert_call("debug_grant_item", &["1", "5090050", "1"]); @@ -87,7 +89,9 @@ fn inventory_changes_preserve_accepted_trade_items() { shard.assert_sql("UPDATE game_spell_reagent SET item_entry = 5090054 WHERE id = 40721368"); shard.assert_sql("INSERT INTO game_spell_reagent (id,spell_id,item_entry,count) VALUES (40721369,5090171,5090052,1)"); shard.assert_sql("DELETE FROM game_creature_move_schedule"); - shard.assert_sql("UPDATE game_world_entity SET power = 100 WHERE guid = 1"); + shard.assert_sql( + "UPDATE game_world_entity SET simulation_indexed = false, power = 100 WHERE guid = 1", + ); let before_craft = shard.query_rows("SELECT * FROM game_item_instance"); shard.assert_call("debug_begin_cast", &["1", "5090171", "1"]); assert_eq!( diff --git a/module/tests/proximity_emotes.rs b/module/tests/proximity_emotes.rs index f8bc4b272..5ba049185 100644 --- a/module/tests/proximity_emotes.rs +++ b/module/tests/proximity_emotes.rs @@ -63,7 +63,7 @@ fn an_e_line_is_one_universal_row_and_a_dead_or_creature_only_type_is_refused() ); standalone.assert_sql(&format!( - "UPDATE game_world_entity SET dead = true WHERE guid = {CHARACTER}" + "UPDATE game_world_entity SET simulation_indexed = false, dead = true WHERE guid = {CHARACTER}" )); refused( &standalone, diff --git a/module/tests/queued_strike_fizzle.rs b/module/tests/queued_strike_fizzle.rs index fd26528e8..0f934255a 100644 --- a/module/tests/queued_strike_fizzle.rs +++ b/module/tests/queued_strike_fizzle.rs @@ -164,7 +164,7 @@ fn spawn_target(standalone: &Standalone) -> u64 { .max() .expect("the spawned wolf must exist"); standalone.assert_sql(&format!( - "UPDATE game_world_entity SET health = 100000, max_health = 100000 WHERE guid = {guid}" + "UPDATE game_world_entity SET simulation_indexed = false, health = 100000, max_health = 100000 WHERE guid = {guid}" )); guid } diff --git a/module/tests/random_property_stats.rs b/module/tests/random_property_stats.rs index 5767111ec..6db2345dd 100644 --- a/module/tests/random_property_stats.rs +++ b/module/tests/random_property_stats.rs @@ -119,7 +119,7 @@ fn direct_pools_and_regeneration_follow_working_equipment() { shard.assert_sql( "UPDATE game_item_instance SET durability = 70 WHERE owner_guid = 1 AND slot = 15", ); - shard.assert_sql("UPDATE game_world_entity SET health = 100, max_health = 100000, power = 100, max_power = 100000, spirit = 30, mana_regen_paused_until_ms = 0 WHERE guid = 1"); + shard.assert_sql("UPDATE game_world_entity SET simulation_indexed = false, health = 100, max_health = 100000, power = 100, max_power = 100000, spirit = 30, mana_regen_paused_until_ms = 0 WHERE guid = 1"); assert!( poll_until(POLL_TIMEOUT, || { let row = world_row(&shard); @@ -128,7 +128,7 @@ fn direct_pools_and_regeneration_follow_working_equipment() { "the scheduled regeneration pass did not apply the expected health and mana" ); - shard.assert_sql("UPDATE game_world_entity SET power = 100, mana_regen_paused_until_ms = 9999999999999 WHERE guid = 1"); + shard.assert_sql("UPDATE game_world_entity SET simulation_indexed = false, power = 100, mana_regen_paused_until_ms = 9999999999999 WHERE guid = 1"); assert!( poll_until(POLL_TIMEOUT, || world_row(&shard)["power"] == "108"), "flat mana regeneration stopped during the five-second rule" @@ -141,7 +141,7 @@ fn direct_pools_and_regeneration_follow_working_equipment() { .unwrap()["guid"] .clone(); shard.assert_sql( - "UPDATE game_world_entity SET health = 100, power = 100000, godmode = true WHERE guid = 1", + "UPDATE game_world_entity SET simulation_indexed = false, health = 100, power = 100000, godmode = true WHERE guid = 1", ); shard.assert_call("debug_engage", &[&wolf, "1"]); assert!( @@ -285,7 +285,7 @@ fn spell_power_selects_school_and_healing_power_only_affects_heals() { shard.assert_sql("INSERT INTO game_spell (spell_id,name,power_type,cost,cast_time_ms,gcd_ms,cooldown_ms,range_yd,duration_ms,school_mask,dispel_type,mechanic,max_stacks,aura_interrupt,attributes,spell_level,max_level,is_negative,cast_flags,stances,family_name,family_flags,proc_flags,proc_chance,proc_charges) VALUES (5090233,'Stat fixture heal',0,0,0,0,0,40,0,2,0,0,0,0,0,1,0,false,0,0,0,0,0,0,0)"); shard.assert_sql("INSERT INTO game_spell_effect (id,spell_id,effect_index,kind,base_points,die_sides,per_level,period_ms,target,radius_yd,chain_targets,trigger_spell,effect_mechanic,p0,p0_kind,p1,script_id,enters_combat) VALUES (20360932,5090233,0,2,100,0,0.0,0,0,0.0,0,0,0,0,0,0,0,false)"); shard - .assert_sql("UPDATE game_world_entity SET health = 100, max_health = 10000 WHERE guid = 1"); + .assert_sql("UPDATE game_world_entity SET simulation_indexed = false, health = 100, max_health = 10000 WHERE guid = 1"); shard.assert_call("debug_cast_at", &["1", "5090233", "1"]); let healed: u32 = world_row(&shard)["health"].parse().unwrap(); assert_eq!( @@ -322,7 +322,7 @@ fn weapon_damage_uses_the_selected_main_off_and_ranged_item() { .unwrap()["guid"] .clone(); shard.assert_sql(&format!( - "UPDATE game_world_entity SET armor = 0, health = 100000, max_health = 100000 WHERE guid = {wolf}" + "UPDATE game_world_entity SET simulation_indexed = false, armor = 0, health = 100000, max_health = 100000 WHERE guid = {wolf}" )); shard.assert_call("debug_compute_swing", &["1", &wolf]); let main = readout(&shard, "swing"); @@ -349,7 +349,7 @@ fn weapon_damage_uses_the_selected_main_off_and_ranged_item() { "UPDATE game_item_template SET inventory_type = 22, subclass = 7 WHERE entry = {ITEM}" )); shard.assert_sql("UPDATE game_item_instance SET slot = 16, durability = 70 WHERE owner_guid = 1 AND slot = 17"); - shard.assert_sql("UPDATE game_world_entity SET strength = 0, agility = 0, health = 100000, max_health = 100000 WHERE guid = 1"); + shard.assert_sql("UPDATE game_world_entity SET simulation_indexed = false, strength = 0, agility = 0, health = 100000, max_health = 100000 WHERE guid = 1"); let swings = shard.subscribe(&["SELECT * FROM game_combat_event_live WHERE attacker_guid = 1"]); shard.assert_call("debug_engage", &["1", &wolf]); assert!( diff --git a/module/tests/scripted_spell_effect.rs b/module/tests/scripted_spell_effect.rs index 187eb57a0..cc2f70c4a 100644 --- a/module/tests/scripted_spell_effect.rs +++ b/module/tests/scripted_spell_effect.rs @@ -59,7 +59,7 @@ fn an_enabled_scripted_effect_heals_the_resolved_target_and_credits_xp_to_the_ca let wolf = spawn_wolf(&standalone); prevent_scheduled_regeneration(&standalone, wolf); standalone.assert_sql(&format!( - "UPDATE game_world_entity SET health = 50, max_health = 1000 WHERE guid = {wolf}" + "UPDATE game_world_entity SET simulation_indexed = false, health = 50, max_health = 1000 WHERE guid = {wolf}" )); let xp_before = xp(&standalone, PLAYER); @@ -115,7 +115,7 @@ fn a_scripted_effect_with_script_id_zero_stays_the_vanilla_no_op() { let wolf = spawn_wolf(&standalone); prevent_scheduled_regeneration(&standalone, wolf); standalone.assert_sql(&format!( - "UPDATE game_world_entity SET health = 50, max_health = 1000 WHERE guid = {wolf}" + "UPDATE game_world_entity SET simulation_indexed = false, health = 50, max_health = 1000 WHERE guid = {wolf}" )); let xp_before = xp(&standalone, PLAYER); @@ -274,7 +274,7 @@ fn a_failing_scripted_effect_discards_only_its_own_staged_effects() { let wolf = spawn_wolf(&standalone); prevent_scheduled_regeneration(&standalone, wolf); standalone.assert_sql(&format!( - "UPDATE game_world_entity SET health = 100, max_health = 100000 WHERE guid = {wolf}", + "UPDATE game_world_entity SET simulation_indexed = false, health = 100, max_health = 100000 WHERE guid = {wolf}", )); standalone.assert_call( @@ -378,7 +378,7 @@ fn prevent_scheduled_regeneration(standalone: &Standalone, guid: u64) { .parse::() .expect("unit flags are a number"); standalone.assert_sql(&format!( - "UPDATE game_world_entity SET unit_flags = {}, combat_until_ms = {} WHERE guid = {guid}", + "UPDATE game_world_entity SET simulation_indexed = false, unit_flags = {}, combat_until_ms = {} WHERE guid = {guid}", flags | unit_flags::IN_COMBAT, u64::MAX, )); diff --git a/module/tests/self_resurrection.rs b/module/tests/self_resurrection.rs index d16f1a4bc..099ea87d2 100644 --- a/module/tests/self_resurrection.rs +++ b/module/tests/self_resurrection.rs @@ -158,7 +158,7 @@ fn start(name: &str) -> Standalone { standalone.publish_module(); standalone.assert_call("debug_spawn_player_entity", &[&PLAYER.to_string()]); standalone.assert_sql(&format!( - "UPDATE game_world_entity SET max_health = 1000, health = 1000 WHERE guid = {PLAYER}" + "UPDATE game_world_entity SET simulation_indexed = false, max_health = 1000, health = 1000 WHERE guid = {PLAYER}" )); insert_spell( &standalone, @@ -204,7 +204,7 @@ fn set_class(standalone: &Standalone, class: u32, power_type: u32, max_power: u3 let bytes = number(&entity(standalone), "unit_bytes_0"); let bytes = (bytes & 0x00FF_00FF) | (class << 8) | (power_type << 24); standalone.assert_sql(&format!( - "UPDATE game_world_entity SET unit_bytes_0 = {bytes}, max_power = {max_power}, \ + "UPDATE game_world_entity SET simulation_indexed = false, unit_bytes_0 = {bytes}, max_power = {max_power}, \ power = {max_power} WHERE guid = {PLAYER}" )); } @@ -212,7 +212,7 @@ fn set_class(standalone: &Standalone, class: u32, power_type: u32, max_power: u3 fn hold_vitals(standalone: &Standalone) { let flags = number(&entity(standalone), "unit_flags"); standalone.assert_sql(&format!( - "UPDATE game_world_entity SET unit_flags = {}, combat_until_ms = {}, \ + "UPDATE game_world_entity SET simulation_indexed = false, unit_flags = {}, combat_until_ms = {}, \ mana_regen_paused_until_ms = {} WHERE guid = {PLAYER}", flags | unit_flags::IN_COMBAT, u64::MAX, diff --git a/module/tests/simulation_activity.rs b/module/tests/simulation_activity.rs new file mode 100644 index 000000000..a37edcfc1 --- /dev/null +++ b/module/tests/simulation_activity.rs @@ -0,0 +1,128 @@ +mod support; + +use support::{lone_wolf, number, poll_until, Standalone, POLL_TIMEOUT}; + +#[test] +#[ignore = "requires a healthy machine with the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn migration_defaults_are_repaired_before_recovery_outside_active_cells() { + let mut shard = Standalone::start("simulation-backfill"); + let wolf = lone_wolf(&mut shard); + let guid = number::(&wolf, "guid").to_string(); + let rage = u32::from(lyracore_shared::packing::power_type::RAGE) << 24; + shard.assert_sql(&format!( + "UPDATE game_world_entity SET health = 1, max_health = 100, power = 100, \ + max_power = 100, unit_bytes_0 = {rage}, unit_flags = 0, \ + regeneration_pending = false, combat_active = false, simulation_indexed = false \ + WHERE guid = {guid}" + )); + + assert!(poll_until(POLL_TIMEOUT, || { + let rows = shard.query_rows(&format!( + "SELECT health, power, simulation_indexed, regeneration_pending \ + FROM game_world_entity WHERE guid = {guid}" + )); + let row = &rows[0]; + row["simulation_indexed"] == "true" + && row["regeneration_pending"] == "true" + && number::(row, "health") > 1 + && number::(row, "power") < 100 + })); + + shard.assert_call("debug_set_health", &[&guid, "100"]); + shard.assert_call("debug_set_power", &[&guid, "0"]); + let rows = shard.query_rows(&format!( + "SELECT regeneration_pending, simulation_indexed FROM game_world_entity WHERE guid = {guid}" + )); + assert_eq!(rows[0]["simulation_indexed"], "true"); + assert_eq!(rows[0]["regeneration_pending"], "false"); +} + +#[test] +#[ignore = "requires a healthy machine with the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn backfill_recovers_combat_flags_without_restarting_the_combat_deadline() { + let mut shard = Standalone::start("simulation-combat-backfill"); + let wolf = lone_wolf(&mut shard); + let guid = number::(&wolf, "guid").to_string(); + let in_combat = lyracore_shared::constants::unit_flags::IN_COMBAT; + shard.assert_sql(&format!( + "UPDATE game_world_entity SET unit_flags = {in_combat}, combat_until_ms = 0, \ + combat_active = false, simulation_indexed = false WHERE guid = {guid}" + )); + assert!(poll_until(POLL_TIMEOUT, || { + let rows = shard.query_rows(&format!( + "SELECT unit_flags, combat_active, simulation_indexed, combat_until_ms \ + FROM game_world_entity WHERE guid = {guid}" + )); + let row = &rows[0]; + row["simulation_indexed"] == "true" + && row["combat_active"] == "false" + && number::(row, "unit_flags") & in_combat == 0 + && number::(row, "combat_until_ms") == 0 + })); +} + +#[test] +#[ignore = "requires a healthy machine with the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn backfill_batches_pause_regeneration_until_every_entity_is_indexed() { + let mut shard = Standalone::start("simulation-batched-backfill"); + let wolf = lone_wolf(&mut shard); + let packed = (1..=512) + .map(|index| { + let guid = (0xF130u64 << 48) | (51000u64 << 24) | (1_000_000 + index); + format!( + "{guid},51000,{},{},{},{},0,0", + wolf["map_id"], wolf["x"], wolf["y"], wolf["z"] + ) + }) + .collect::>() + .join(";"); + shard.assert_call( + "import_creature_spawns_append", + &[&serde_json::to_string(&packed).unwrap()], + ); + assert!(poll_until(POLL_TIMEOUT, || { + shard + .query_rows("SELECT guid FROM game_world_entity WHERE entry = 51000") + .len() + == 513 + })); + shard.assert_sql("DELETE FROM game_creature_move_schedule"); + shard.assert_sql("DELETE FROM game_creature_spawn"); + shard.assert_sql("DELETE FROM game_world_entity WHERE entry != 51000"); + assert_eq!( + shard.query_rows("SELECT guid FROM game_world_entity").len(), + 513 + ); + + shard.assert_call("debug_arm_instance_tick", &["0", "4000"]); + shard.assert_sql(&format!( + "UPDATE game_creature_move_schedule SET instance_id = {}", + u64::MAX + )); + let updates = shard.subscribe(&["SELECT * FROM game_world_entity"]); + shard.assert_sql( + "UPDATE game_world_entity SET health = 1, max_health = 100, power = 0, max_power = 0, \ + unit_flags = 0, regeneration_pending = false, combat_active = false, \ + simulation_indexed = false", + ); + assert!(poll_until(POLL_TIMEOUT, || { + shard + .query_rows("SELECT health, simulation_indexed FROM game_world_entity") + .iter() + .all(|row| row["simulation_indexed"] == "true" && number::(row, "health") > 1) + })); + assert!(poll_until(POLL_TIMEOUT, || { + updates + .inserts("game_world_entity") + .iter() + .any(|row| row["simulation_indexed"] == true && row["health"].as_u64().unwrap() > 1) + })); + let first_batch: Vec<_> = updates + .inserts("game_world_entity") + .into_iter() + .filter(|row| row["simulation_indexed"] == true) + .take(512) + .collect(); + assert_eq!(first_batch.len(), 512); + assert!(first_batch.iter().all(|row| row["health"] == 1)); +} diff --git a/module/tests/stand_state.rs b/module/tests/stand_state.rs index fb090e16b..6d704418f 100644 --- a/module/tests/stand_state.rs +++ b/module/tests/stand_state.rs @@ -62,7 +62,7 @@ fn a_player_sits_and_stands_but_cannot_pick_a_server_state() { assert_eq!(unit_bytes_1(&standalone), upper_bytes, "standing"); standalone.assert_sql(&format!( - "UPDATE game_world_entity SET dead = true WHERE guid = {CHARACTER}" + "UPDATE game_world_entity SET simulation_indexed = false, dead = true WHERE guid = {CHARACTER}" )); set_stand_state(&standalone, "1"); assert_eq!(unit_bytes_1(&standalone), upper_bytes | 1, "a ghost sits");