From 186e683721bde554142a59f6efe0c27988731a38 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 13:40:28 +0200 Subject: [PATCH 01/21] refactor(module): move the post-publish repair pass out of the debug feature `debug_repair_after_publish` restores seeds and schedules on every published shard, so it now lives in an ungated `operations` module. The reducer keeps its name and arguments, so the CLI, scripts and runbooks keep working. The debug module no longer claims that a production publish leaves `debug_reducers` off: every publish enables it. The crate root names SpacetimeDB 2.7.1, the pinned version. Co-Authored-By: Claude Opus 5.5 (1M context) --- module/src/auction.rs | 9 ---- module/src/debug/mod.rs | 33 ++++--------- module/src/lib.rs | 12 ++--- module/src/mail_timer.rs | 4 +- module/src/{debug/repair.rs => operations.rs} | 46 ++++--------------- module/src/seed.rs | 3 -- module/src/tripwires.rs | 4 +- 7 files changed, 26 insertions(+), 85 deletions(-) rename module/src/{debug/repair.rs => operations.rs} (86%) diff --git a/module/src/auction.rs b/module/src/auction.rs index 0683bece2..89068de6e 100644 --- a/module/src/auction.rs +++ b/module/src/auction.rs @@ -4550,7 +4550,6 @@ pub(crate) fn character_has_auction_value(ctx: &ReducerContext, character_guid: /// The exact English subjects LyraCore's earlier auction code sent, each a `Character` mail with /// no vanilla twin. A real player's letter can carry the same words, so the subject alone never /// authorizes a re-tag; [`legacy_mail_matches_shape`] is the rest of the check. -#[cfg_attr(not(feature = "debug_reducers"), allow(dead_code))] const LEGACY_AUCTION_SUBJECTS: &[(&str, AuctionMailAction)] = &[ ("Auction outbid", AuctionMailAction::Outbid), ("Auction won", AuctionMailAction::Won), @@ -4564,7 +4563,6 @@ const LEGACY_AUCTION_SUBJECTS: &[(&str, AuctionMailAction)] = &[ /// The pure half of the repair: which `AuctionMailAction` a legacy row's exact English subject /// maps to, or `None` for a subject this repair does not recognize. -#[cfg_attr(not(feature = "debug_reducers"), allow(dead_code))] fn legacy_auction_action(subject: &str) -> Option { LEGACY_AUCTION_SUBJECTS .iter() @@ -4584,7 +4582,6 @@ fn legacy_auction_action(subject: &str) -> Option { /// - Won carries an item and no money; Sold carries money and no item. Neither has a guid-0 tell — /// a real player letter can be titled either with a real sender and the right shape — so /// [`legacy_repair_authorization`] is what actually clears them, not this function. -#[cfg_attr(not(feature = "debug_reducers"), allow(dead_code))] fn legacy_mail_matches_shape(mail: &crate::Mail) -> bool { if mail.cod != 0 || !mail.body.is_empty() { return false; @@ -4601,7 +4598,6 @@ fn legacy_mail_matches_shape(mail: &crate::Mail) -> bool { } /// A converted row's house and the item reference its vanilla subject encodes. -#[cfg_attr(not(feature = "debug_reducers"), allow(dead_code))] struct LegacyRepairTarget { house: u32, item_entry: u32, @@ -4638,7 +4634,6 @@ struct LegacyRepairTarget { /// to the race lookup only if no receipt matches their item (a legacy row that predates receipts, /// say); Outbid and the deferred bid refund have no receipt to draw from at all — a bidder is never /// a receipt's actor — and always use the race lookup. -#[cfg_attr(not(feature = "debug_reducers"), allow(dead_code))] fn legacy_repair_authorization( ctx: &ReducerContext, mail: &crate::Mail, @@ -4723,7 +4718,6 @@ fn legacy_repair_authorization( /// skips a whole copper as price climbs by one (its cut grows by at most one copper per copper of /// price), so every integer between its low and high ends is reachable — checking the two ends /// bounds every price in between too. -#[cfg_attr(not(feature = "debug_reducers"), allow(dead_code))] fn receipt_could_pay_out(receipt: &AuctionOperationReceipt, money: u32) -> bool { let Some(low) = seller_proceeds(receipt.start_bid, receipt.deposit, receipt.consignment_rate) else { @@ -4747,7 +4741,6 @@ fn receipt_could_pay_out(receipt: &AuctionOperationReceipt, money: u32) -> bool /// (`crates/lyracore-shared/src/auction.rs`). Falls back to the neutral house (7) for a recipient /// with no Character row — the same fallback that function uses for a template it cannot place on /// either team, and Realm-core's only outcome, since it carries no Character rows at all. -#[cfg_attr(not(feature = "debug_reducers"), allow(dead_code))] fn legacy_character_house(ctx: &ReducerContext, recipient_guid: u64) -> u32 { match crate::helpers::character_by_guid(ctx, recipient_guid) { Some(character) @@ -4764,7 +4757,6 @@ fn legacy_character_house(ctx: &ReducerContext, recipient_guid: u64) -> u32 { /// Family name `repair_legacy_auction_mail` stamps in `game_import_meta` once it has run on a /// database, so a later publish's repair pass does not re-scan mail a player wrote after the /// re-tag — including a letter that happens to name one of [`LEGACY_AUCTION_SUBJECTS`]. -#[cfg_attr(not(feature = "debug_reducers"), allow(dead_code))] const LEGACY_AUCTION_MAIL_REPAIR_FAMILY: &str = "repair_legacy_auction_mail"; /// Re-tag auction mail written before the vanilla Auction Mail format shipped to the vanilla @@ -4784,7 +4776,6 @@ const LEGACY_AUCTION_MAIL_REPAIR_FAMILY: &str = "repair_legacy_auction_mail"; /// sold", which carry no tell of their own — a durable listing receipt backs its claim. Returns /// `(converted, unmapped)`; a nonzero `unmapped` is an auction-looking row this repair left alone, /// worth a human look. -#[cfg_attr(not(feature = "debug_reducers"), allow(dead_code))] pub(crate) fn repair_legacy_auction_mail(ctx: &ReducerContext) -> (u64, u64) { if ctx .db diff --git a/module/src/debug/mod.rs b/module/src/debug/mod.rs index ccb8a1174..610676ff8 100644 --- a/module/src/debug/mod.rs +++ b/module/src/debug/mod.rs @@ -3,18 +3,17 @@ //! (teleport / set HP / spawn-at-feet / force-cast / set level / clear creatures) so most ⭐ tests //! lose their blind 3D-navigation step and become deterministic. //! -//! # PROD-SAFETY GATE -//! The whole module is behind `#![cfg(feature = "debug_reducers")]` — with the feature OFF (the -//! default, so a plain `spacetime build` / `cargo build` and any production publish) this file -//! compiles to nothing and the reducers don't exist in the module at all. The test build / the -//! automation harness opt in explicitly: +//! # Feature gate +//! `lib.rs` declares `mod debug;` behind `#[cfg(feature = "debug_reducers")]`. A default build +//! compiles this directory out. Every `lyracore publish`, production included, builds with the +//! feature on, so these reducers exist on every published database: //! //! ```text //! spacetime publish -s local --build-options='--features=debug_reducers' lyracore //! ``` //! -//! (`--build-options` is forwarded verbatim to `cargo build --target=wasm32-… --release`, so the -//! feature reaches the wasm compile — verified against the 2.5 CLI.) +//! `docs/danger-zones.md` is authoritative on that posture. The post-publish repair pass lives in +//! the ungated `operations` module. //! //! # CRITICAL identity caveat (docs §6 / HANDOFF §10.2) //! A `spacetime call` runs as the **CLI identity**, which is NOT a player's bound identity. So every @@ -22,20 +21,10 @@ //! MUST NOT use `entity_by_owner(ctx, ctx.sender())` (that only resolves the gateway's per-player //! connection identity). This is the single most important difference from the player reducers. //! -//! # LAYOUT -//! Split along the section banners after the reseed/rearm collapse: this file (`mod`) holds the -//! world/combat/item levers + the shared `equip_into` helper; `readout` is the `DebugReadout` table + -//! its two writers; `audit` is the class-kit/quest-chain content audits; `repair` is the consolidated -//! post-publish repair pass; `encounter`/`instance` are the work-item 228/190 operator levers; and -//! `fingerprint` is the catalogue-parity content hash. `lib.rs`'s single `#[cfg(feature = -//! "debug_reducers")] mod debug;` gates the whole directory unchanged — a directory module resolves -//! identically to a single file as far as that `cfg` and the crate-root `pub use debug::*;` are -//! concerned. -// NOTE: no `#![cfg(feature = "debug_reducers")]` here — `lib.rs` already gates `mod debug;` on that -// exact feature, so repeating it inside the file is a duplicated `cfg` (clippy::duplicated_attributes) -// that reads as a second, independent gate while adding nothing. lib.rs:175 is THE gate; if it ever -// loses its `#[cfg]`, this whole directory compiles into production — that is what preflight check 1 -// (`cargo check --features=debug_reducers`) and the prod-safety note above lib.rs's `mod debug;` guard. +//! # Layout +//! This file holds the world, combat and item levers and the shared `equip_into` helper. `readout` is +//! the `DebugReadout` table and its writers, `audit` the class-kit and quest-chain content audits, +//! `encounter` and `instance` the operator levers, and `fingerprint` the catalogue-parity hash. mod audit; mod auth; @@ -47,7 +36,6 @@ mod mail; mod meeting_stone; mod package_config; mod readout; -mod repair; mod runtime_script; pub use audit::*; @@ -60,7 +48,6 @@ pub use mail::*; pub use meeting_stone::*; pub use package_config::*; pub use readout::*; -pub use repair::*; pub use runtime_script::*; use lyracore_shared::{constants, spatial}; diff --git a/module/src/lib.rs b/module/src/lib.rs index 8148a3934..95eb8614b 100644 --- a/module/src/lib.rs +++ b/module/src/lib.rs @@ -14,7 +14,7 @@ //! re-exports carry those traits to the crate root. Scheduled tables are co-located with the //! reducer they name (`scheduled(...)` resolves the reducer in its own module's scope). //! -//! Built against SpacetimeDB 2.5 (`accessor =` table syntax; `ctx.sender()` / +//! Built against SpacetimeDB 2.7.1 (`accessor =` table syntax; `ctx.sender()` / //! `ctx.db.()`; RLS via the `unstable` `client_visibility_filter`). Categories, //! visibility, and field-index annotations follow `docs/schema.md`. @@ -309,12 +309,8 @@ mod config; mod corpse; mod creatures; mod exploration; -// Layer 1 test-only reducers (debug_teleport / set_health / spawn_at_feet / force_cast / set_level / -// clear_creatures) for the client-automation harness. PROD-SAFE: the whole module is behind the -// `debug_reducers` Cargo feature (default OFF) — a plain build / production publish compiles it out -// entirely. Enable for the test build via `--build-options='--features=debug_reducers'`. See -// debug/mod.rs (split this into a directory along its section banners: mod/readout/audit/ -// repair/encounter/instance/fingerprint). +// Client-automation test reducers, behind the `debug_reducers` feature. Every publish enables the +// feature, so they exist on production databases too; see `debug/mod.rs`. #[cfg(feature = "debug_reducers")] mod debug; // Encounter kernel (work-item 228): instance-scoped encounter state, HP-threshold/death/GO-use @@ -378,6 +374,8 @@ mod motion; /// idempotent dismount every removal path converges on. mod mount; pub mod nav; +/// Operator reducers every build carries: the post-publish repair pass. +mod operations; /// Package-owned Accounts: the operation a Package creates its session-less Characters through, and /// the record of which Package owns each Account. mod package_account; diff --git a/module/src/mail_timer.rs b/module/src/mail_timer.rs index e6f12184e..4cc0e7610 100644 --- a/module/src/mail_timer.rs +++ b/module/src/mail_timer.rs @@ -180,9 +180,7 @@ fn spaced(dues: &[Timestamp], now: Timestamp) -> Vec { /// Arm a timer for every Mail that has none: Mail written before the timer existed, or Mail a /// Transfer imported. Mail past its life expires shortly after this transaction commits, spaced -/// by [`BACKLOG_SPACING_MICROS`]. Returns how many timers it armed. Its caller, -/// `debug_repair_after_publish`, is a debug reducer. -#[cfg_attr(not(feature = "debug_reducers"), allow(dead_code))] +/// by [`BACKLOG_SPACING_MICROS`]. Returns how many timers it armed. pub(crate) fn arm_missing(ctx: &ReducerContext) -> u64 { let timers = ctx.db.game_mail_timer(); let untimed: Vec = ctx diff --git a/module/src/debug/repair.rs b/module/src/operations.rs similarity index 86% rename from module/src/debug/repair.rs rename to module/src/operations.rs index d6beabe67..3f2e9a583 100644 --- a/module/src/debug/repair.rs +++ b/module/src/operations.rs @@ -1,6 +1,4 @@ -//! The consolidated post-publish repair pass — the one reducer the publish script calls after -//! every deploy to re-seed/rearm everything `seed::init` would have inserted on a fresh database but -//! that a plain auto-migrate republish silently leaves absent. +//! Operator reducers that every build carries, whatever its Cargo features. use spacetimedb::{log, reducer, ReducerContext, ScheduleAt, Table, TimeDuration}; @@ -14,29 +12,16 @@ use crate::{ CreatureMoveSchedule, DuelSchedule, GroundAreaSchedule, PetCareSchedule, }; -/// Consolidated post-publish repair pass. SpacetimeDB's `init` reducer runs ONLY on a -/// database's first-ever publish, never again on a plain (auto-migrate) republish — so every -/// seed/fixture/schedule row `seed::init` inserts is silently ABSENT on an already-migrated live DB -/// until something re-seeds it by hand. That "something" used to be 17 near-identical -/// `debug_seed_*`/`debug_ensure_*`/`debug_rearm_*` reducers, one per fixture family — a model that -/// failed operationally at least four times (aura schedule, ground-area schedule, regen fixture, -/// catalogue drift), and forced `debug_seed_scenario_fixtures` to bolt two extra seeders onto -/// itself as a stopgap when a live suite hard-failed on a missing fixture. Every underlying seeder -/// is idempotent-if-absent by construction (each deleted reducer documented the same precedent), so -/// running all of them unconditionally on every publish — fresh or auto-migrated — is always safe, -/// and "which of the N do I call after this publish?" collapses to "just this one". +/// The post-publish repair pass. SpacetimeDB runs `init` only on a database's first publish, so +/// every seed, fixture and schedule row `seed::init` inserts stays absent on an auto-migrated +/// database until this pass restores it. Every step is idempotent, so it is safe to run after every +/// publish, on every shard. /// -/// Gated by `require_operator` (the strictest of the reducers it replaces, formerly -/// `debug_rearm_instance_reaper`'s gate alone) rather than left open like most of its predecessors: -/// this is meant to run exactly once per publish, driven by the same CLI/deploy identity that ran -/// `spacetime publish` and (earlier) `claim_operator` — see `scripts/publish-module.sh`, which calls -/// it automatically after every successful publish. On a database's FIRST-EVER publish `init` has -/// already seeded everything and the operator has not been claimed yet, so this call is EXPECTED to -/// no-op-fail with "operator not claimed" there; the publish script treats that as non-fatal. +/// Gated by `require_operator`. On a database's first publish the operator is not yet claimed, so +/// the call fails with "operator not claimed"; `init` has already seeded everything there. /// -/// Stamps ONE `game_import_meta` row (family `"debug_repair_after_publish"`, work-item 216 -/// provenance) with the total row count across every seeded/rearmed family, rather than the 13 -/// separate per-family stamps the old reducers wrote. +/// Stamps one `game_import_meta` row (family `"debug_repair_after_publish"`) with the total row +/// count. The name keeps its `debug_` prefix because the CLI, scripts and runbooks call it by name. #[reducer] #[allow(clippy::too_many_lines)] // One idempotent repair pass per row family. pub fn debug_repair_after_publish(ctx: &ReducerContext) -> Result<(), String> { @@ -399,16 +384,3 @@ pub fn debug_repair_after_publish(ctx: &ReducerContext) -> Result<(), String> { log::info!("debug_repair_after_publish: repaired {total} fixture/schedule row(s), including missing Auction expiries and {mail_timers} Mail Timer(s)"); Ok(()) } - -#[cfg(test)] -mod pet_care_schedule_tests { - #[test] - fn post_publish_repair_ensures_the_authoritative_pet_care_clock() { - let body = crate::test_scan::code_of( - include_str!("repair.rs"), - "pub fn debug_repair_after_publish(", - ); - assert!(body.contains("game_pet_care_schedule().iter().next().is_some()")); - assert!(body.contains("CARE_INTERVAL_MICROS")); - } -} diff --git a/module/src/seed.rs b/module/src/seed.rs index fc5ec3ff9..b56e98af6 100644 --- a/module/src/seed.rs +++ b/module/src/seed.rs @@ -1479,7 +1479,6 @@ pub(crate) fn reconcile_curated_starter_role_levels(ctx: &ReducerContext) -> u32 repaired } -#[cfg(feature = "debug_reducers")] fn legacy_lesser_heal_header(spell: &Spell) -> bool { spell.spell_id == 2050 && spell.name == "Lesser Heal" @@ -1508,7 +1507,6 @@ fn legacy_lesser_heal_header(spell: &Spell) -> bool { && spell.proc_charges == 0 } -#[cfg(feature = "debug_reducers")] fn legacy_lesser_heal_effect(effect: &SpellEffect) -> bool { effect.id == (2050u64 << 2) && effect.spell_id == 2050 @@ -1532,7 +1530,6 @@ fn legacy_lesser_heal_effect(effect: &SpellEffect) -> bool { /// Repair only the exact curated Lesser Heal row, before or after its level reconciliation. /// Imported or tuned spell data does not match both complete shapes and remains authoritative. -#[cfg(feature = "debug_reducers")] pub(crate) fn repair_lesser_heal_target(ctx: &ReducerContext) -> u64 { let Some(spell) = ctx.db.game_spell().spell_id().find(2050) else { return 0; diff --git a/module/src/tripwires.rs b/module/src/tripwires.rs index 4f6eea250..c481df835 100644 --- a/module/src/tripwires.rs +++ b/module/src/tripwires.rs @@ -1199,9 +1199,7 @@ mod character_fence_tripwire { ("module/src/talent.rs", 1, "the guid comes from the caller's own entity_by_owner-resolved entity"), ("module/src/gm.rs", 2, "set_gm_level is FENCED (character_by_name); the two raw reads are gm_command's own gm_level probe and its .money write, both on the caller's entity_by_owner-resolved guid"), ("module/src/loot/mod.rs", 1, "DEFER: credit_purse writes the durable row AFTER folding the delta into the escrowed blob (`transfer::defer_money_delta`) — refusing would drop a third party's copper"), - // Split the former single `debug.rs` into a directory; this lookup lives in the one - // reducer the collapse put it in. - ("module/src/debug/repair.rs", 1, "`debug_repair_after_publish`'s gm-tester backfill (guid 1, formerly the standalone `debug_seed_gm_tester`); every debug WRITER that touches character state is fenced"), + ("module/src/operations.rs", 1, "`debug_repair_after_publish`'s gm-tester backfill (guid 1); every debug WRITER that touches character state is fenced"), ]; /// `(path relative to a Package's own root, allowed raw-lookup count, verdict + why)` — keyed From 9a86307fd35f1df1b3863814d689b9e75fb5b019 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 13:40:35 +0200 Subject: [PATCH 02/21] test(module): delete tests that pin source text Delete every Module test that asserted a function body or source file contains, lacks or equals a literal string, including the verbatim pins of the creature cycle, mail escrow and transfer production adapters. Behaviour tests in the same files stay. A rename or reformat no longer turns CI red. Co-Authored-By: Claude Opus 5.5 (1M context) --- module/src/auction.rs | 67 +- module/src/auth.rs | 162 ----- module/src/breath.rs | 21 - module/src/channel.rs | 13 - module/src/chat.rs | 37 -- module/src/combat/death.rs | 156 +---- module/src/combat/engage.rs | 22 - module/src/combat/swing.rs | 198 ------ module/src/config.rs | 60 -- module/src/creatures/cycle/ctx.rs | 3 +- module/src/creatures/cycle/harness.rs | 473 -------------- module/src/creatures/eventai/edges.rs | 111 ---- module/src/creatures/eventai/fixtures.rs | 10 - module/src/creatures/eventai/mobility.rs | 12 - module/src/creatures/eventai/mod.rs | 171 ----- module/src/creatures/eventai/relay.rs | 48 -- module/src/creatures/hunter_pet.rs | 42 -- module/src/creatures/pet_care.rs | 7 - module/src/creatures/pet_progression.rs | 29 - module/src/creatures/spawn.rs | 43 +- module/src/creatures/tick/lifecycle.rs | 243 ------- module/src/creatures/tick/mod.rs | 43 -- module/src/duel.rs | 20 - module/src/gc.rs | 24 - module/src/group.rs | 137 ---- module/src/guild/fee.rs | 33 - module/src/guild/mod.rs | 13 - module/src/guild/petition.rs | 14 - module/src/gw.rs | 41 -- module/src/instance.rs | 258 -------- module/src/items/economy.rs | 96 --- module/src/items/inventory.rs | 104 --- module/src/load.rs | 61 -- module/src/loot/mod.rs | 28 - module/src/loot/rolls.rs | 80 --- module/src/mail.rs | 146 ----- module/src/mail_escrow.rs | 158 ----- module/src/mail_text.rs | 35 -- module/src/motion.rs | 81 +-- module/src/mount.rs | 590 ----------------- module/src/realm_chat.rs | 16 - module/src/region.rs | 29 - module/src/seed.rs | 169 +---- module/src/spell/control.rs | 70 --- module/src/spell/proc.rs | 136 ---- module/src/spell/scheduler.rs | 32 - module/src/spell/stacking.rs | 196 +----- module/src/spell/tests.rs | 30 - module/src/stats.rs | 83 --- module/src/taxi.rs | 32 - module/src/trade.rs | 35 -- module/src/trainer.rs | 25 - module/src/transfer/harness.rs | 16 +- module/src/transfer/mod.rs | 14 +- module/src/transfer/tests.rs | 768 +---------------------- module/src/tripwires.rs | 1 - module/src/vmap.rs | 101 --- module/src/weather.rs | 29 - module/src/world.rs | 191 +----- 59 files changed, 24 insertions(+), 5839 deletions(-) diff --git a/module/src/auction.rs b/module/src/auction.rs index 89068de6e..bf8b3b650 100644 --- a/module/src/auction.rs +++ b/module/src/auction.rs @@ -4322,9 +4322,9 @@ pub fn debug_stage_legacy_auction_mail_fixture(ctx: &ReducerContext) -> Result<( ..receipt_template }); - // Every row enters `game_mail` through `mail::insert_letter` (the sole writer; the - // `every_mail_row_is_created_by_insert_letter` tripwire enforces it), so every fixture goes - // through `Letter::from_character` exactly as the sending path that produced each shape did. + // Every row enters `game_mail` through `mail::insert_letter` (the sole writer), so every + // fixture goes through `Letter::from_character` exactly as the sending path that produced each + // shape did. crate::mail::insert_letter( ctx, crate::mail::Letter::from_character( @@ -7851,65 +7851,4 @@ mod tests { "a zero cut is a real cut, never the offer" ); } - - #[test] - fn auction_write_reducers_gate_before_reading_caller_named_state() { - use crate::test_scan::code_of; - - for signature in [ - "pub fn gw_auction_list_local(", - "pub fn gw_auction_hold_listing(", - "pub fn realm_auction_commit_listing(", - "pub fn realm_auction_confirm_listing(", - "pub fn realm_auction_settle_listing(", - "pub fn gw_auction_bid_local(", - "pub fn gw_auction_hold_bid(", - "pub fn realm_auction_decide_bid(", - "pub fn gw_auction_finish_bid(", - "pub fn realm_auction_refund_bid(", - "pub fn gw_auction_confirm_bid_refund(", - "pub fn gw_auction_cancel_local(", - "pub fn gw_auction_hold_cancel(", - "pub fn realm_auction_decide_cancel(", - "pub fn debug_stage_auction_cancel_fixture(", - "pub fn debug_stage_auction_buyout_fixture(", - "pub fn debug_verify_auction_buyout_fixture(", - "pub fn debug_stage_auction_expiry_fixture(", - "pub fn debug_replay_auction_expiry_fixture(", - "pub fn debug_verify_auction_expiry_fixture(", - "pub fn debug_stage_legacy_auction_mail_fixture(", - "pub fn debug_verify_legacy_auction_mail_repaired(", - ] { - let body = code_of(include_str!("auction.rs"), signature); - let normalized = body.split_whitespace().collect::>().join(" "); - assert!( - normalized.starts_with("{ crate::helpers::require_operator(ctx)?;"), - "`{signature}` no longer opens with the operator gate. Body was:\n{body}" - ); - } - } - - #[test] - fn auction_expiry_is_scheduler_only() { - let body = crate::test_scan::code_of(include_str!("auction.rs"), "pub fn expire_auction("); - let normalized = body.split_whitespace().collect::>().join(" "); - assert!(normalized.starts_with( - "{ if ctx.sender() != ctx.database_identity() { return Err(\"scheduler only\".to_string());" - )); - } - - #[test] - fn character_delete_refuses_auction_value_before_the_cascade() { - let body = crate::test_scan::code_of(include_str!("auth.rs"), "pub fn delete_character("); - let auction_gate = body - .find("crate::auction::character_has_auction_value") - .expect("character deletion must check Auction value"); - let cascade = body - .find("crate::world::cascade_delete_character") - .expect("character deletion still needs its normal cascade"); - assert!( - auction_gate < cascade, - "Auction value must be fenced before deletion" - ); - } } diff --git a/module/src/auth.rs b/module/src/auth.rs index 5f83bf1ab..4e1bd1a70 100644 --- a/module/src/auth.rs +++ b/module/src/auth.rs @@ -263,30 +263,6 @@ mod operator_claim_tests { Err("operator already claimed".to_string()) ); } - - #[test] - fn reducer_routes_existing_and_new_claims_through_the_tested_decision() { - let got = crate::test_scan::shape_of( - include_str!("auth.rs"), - "pub fn claim_operator(ctx: &ReducerContext) -> Result<(), String> {", - ); - let want = "{ - let ops = ctx.db.game_operator(); - let claimed_identity = ops.id().find(0).map(|operator| operator.identity); - if operator_claim_action(claimed_identity, ctx.sender())? == OperatorClaimAction::Insert { - ops.insert(Operator { id: 0, identity: ctx.sender(), }); - } - Ok(()) - }" - .split_whitespace() - .collect::>() - .join(" "); - assert_eq!( - got, want, - "`claim_operator` must route every claim through the tested decision and insert only \ - on its Insert outcome" - ); - } } // =========================================================================================== @@ -416,33 +392,6 @@ mod alpha_test_tools_tests { assert_eq!(changed_alpha_test_tools(None, true), Err("unknown account")); assert_eq!(realm_account_name("TeSt"), "TEST"); } - - #[test] - fn operator_controls_are_gated_before_their_state_change() { - let src = include_str!("auth.rs"); - for reducer in [ - "pub fn set_alpha_test_tools_enrollment(", - "pub fn grant_alpha_test_tools(", - "pub fn revoke_alpha_test_tools(", - ] { - let body = crate::test_scan::code_of(src, reducer); - assert!( - body.contains("crate::helpers::require_operator(ctx)?;"), - "{reducer} must refuse a caller who is not the Operator" - ); - } - } - - #[test] - fn account_authority_is_an_end_appended_additive_default() { - let src = include_str!("auth.rs"); - let account = &src[src.find("pub struct Account {").expect("Account row")..]; - let account = &account[..account.find("\n}").expect("Account row end")]; - assert!( - account.ends_with("#[default(true)]\n pub alpha_test_tools: bool,"), - "existing Account rows must receive Alpha Test Tools during an additive publish" - ); - } } /// Logon writes the shared session key K and binds the per-account identity. @@ -859,35 +808,6 @@ mod guid_allocator_tests { assert!(!in_guid_range(range, 3_000_000_000)); } - /// …and the WIRING (playbook §8 — the repo's dominant test failure is pinning the helper and - /// not the call site). Character creation must consult the rule BEFORE it writes anything; - /// deleting that one line brings back exactly the silent minting this rule exists to end, with every - /// other test in this file still green. - #[test] - fn character_creation_refuses_before_it_writes_anything() { - // The trailing newline matters: this test module sits ABOVE the functions in the file, so a - // bare needle finds THIS test's own string literal first and scans the test instead. - let src = include_str!("auth.rs"); - let check = crate::test_scan::code_of(src, "pub(crate) fn check_new_character(\n"); - assert!( - check - .trim_start_matches(|c: char| c == '{' || c.is_whitespace()) - .starts_with("require_guid_range(ctx)?"), - "`check_new_character` no longer checks this database's guid range first, so a shard \ - that was never assigned one mints from zero into another shard's guids — silently. \ - Body was:\n{check}" - ); - let body = crate::test_scan::code_of(src, "pub(crate) fn insert_new_character(\n"); - let check_at = body - .find("check_new_character(ctx") - .expect("insert_new_character must run check_new_character"); - let mint_at = body.find("next_character_guid(ctx)").unwrap_or(usize::MAX); - assert!( - check_at < mint_at, - "the range check must come BEFORE the guid is minted, not after" - ); - } - /// Mutation target: flip `.max` to `.min` (or drop the `floor` arm entirely) and this goes red. #[test] fn ratchet_high_water_never_moves_backwards() { @@ -952,88 +872,6 @@ mod guid_allocator_tests { assert_ne!(reissued_guid, 50); assert_eq!(reissued_guid, 51); } - - /// `body_of`/`code_of`/`shape_of` are the shared scan primitives in [`crate::test_scan`] - /// (this used to be six near-identical copies, drifted apart). - use crate::test_scan::shape_of; - - /// Collapse whitespace the same way [`shape_of`] does, for a hand-written "what it SHOULD be" - /// literal — so the `want` side of an equality assertion never has to be hand-space-counted - /// (a likely source of a flaky/wrong pin) and can just be written as normal-looking Rust. - fn norm(s: &str) -> String { - s.split_whitespace().collect::>().join(" ") - } - - /// `next_character_guid` cannot itself be unit-tested (it takes `&ReducerContext`, and this - /// crate has no ctx harness by design — playbook §7), so its glue is pinned by EXACT SHAPE - /// instead of `.contains()` — the round-1 review reproduced FIVE ways to keep a `.contains()` - /// needle present while gutting the function (dead comment, shadowed variable, wrong return - /// value...); equality against the whole body closes all of them, because every one of those - /// mutations changes the body's TEXT, not just whether a substring survives in it. - #[test] - fn next_character_guid_is_exactly_the_pinned_shape() { - let got = shape_of( - include_str!("auth.rs"), - "pub(crate) fn next_character_guid(", - ); - let want = norm( - "{ - let existing = read_high_water(ctx); - let seed = existing - .as_ref() - .map(|r| r.high_water) - .unwrap_or_else(|| legacy_guid_seed_now(ctx)); - let next = allocate_next_guid(seed); - write_high_water(ctx, existing, next); - next - }", - ); - assert_eq!( - got, want, - "next_character_guid's body no longer matches the pinned shape — it must seed from \ - the durable mark first, route through the tested `allocate_next_guid`, persist the \ - result, and return the NEW mark (not the seed). Got:\n{got}" - ); - } - - /// Same reasoning as above, for the other entry point: `bump_guid_high_water` must actually - /// ratchet at `guid` and persist the result, or the defect 1/AC#3 fix (the two call - /// sites in `world::cascade_delete_character` and `transfer::apply_import_blob`) does nothing. - #[test] - fn bump_guid_high_water_is_exactly_the_pinned_shape() { - let got = shape_of( - include_str!("auth.rs"), - "pub(crate) fn bump_guid_high_water(", - ); - let want = norm( - "{ - let range = ctx - .db - .game_guid_range() - .id() - .find(0) - .map(|row| (row.base, row.size)); - if range.is_some() && !in_guid_range(range, guid) { - return; - } - let existing = read_high_water(ctx); - let seed = existing - .as_ref() - .map(|r| r.high_water) - .unwrap_or_else(|| legacy_guid_seed_now(ctx)); - let mark = ratchet_high_water(seed, guid); - if mark == seed && existing.is_some() { - return; - } - write_high_water(ctx, existing, mark); - }", - ); - assert_eq!( - got, want, - "bump_guid_high_water must preserve the legacy floor before range installation, \ - then floor only local GUIDs. It must persist the new mark unless already ahead. Got:\n{got}" - ); - } } // =========================================================================================== diff --git a/module/src/breath.rs b/module/src/breath.rs index 306b0475b..7cb298103 100644 --- a/module/src/breath.rs +++ b/module/src/breath.rs @@ -222,25 +222,4 @@ mod tests { assert_eq!(drowning_damage(100), 20); assert_eq!(drowning_damage(101), 21); } - - #[test] - fn scheduled_tick_never_scans_the_spatial_entity_table() { - let tick = crate::test_scan::code_of(include_str!("breath.rs"), "pub fn tick_breath("); - assert!(tick.contains("let states = ctx.db.game_breath_state();")); - assert!(tick.contains("states.iter()")); - assert!( - !tick.contains("game_world_entity().iter()"), - "breath must iterate only its dedicated non-spatial state table" - ); - } - - #[test] - fn lethal_drowning_routes_through_the_shared_player_death_path() { - let tick = crate::test_scan::code_of(include_str!("breath.rs"), "pub fn tick_breath("); - assert!(tick.contains("crate::combat::kill_player(ctx, entity_guid, entity_guid)")); - assert!( - !tick.contains("hurt.health = 0"), - "drowning must never write a lethal health value inline" - ); - } } diff --git a/module/src/channel.rs b/module/src/channel.rs index 2379b8f32..a01ae4ed0 100644 --- a/module/src/channel.rs +++ b/module/src/channel.rs @@ -879,7 +879,6 @@ pub(crate) fn notify( #[cfg(test)] mod tests { use super::*; - use crate::test_scan::code_of; fn member(id: u64, character_guid: u64, member_flags: u8) -> ChatChannelMember { ChatChannelMember { @@ -903,16 +902,4 @@ mod tests { let members = [member(3, 30, 0x08), member(5, 50, 0)]; assert_eq!(successor(&members), 30); } - - /// The actor is an argument, so the operator gate is the entire authorization. The scan - /// anchors to the opening brace, so a neutralized gate fails it. - #[test] - fn the_realm_channel_op_reducer_is_operator_gated() { - let body = code_of(include_str!("channel.rs"), "pub fn realm_channel_op("); - let normalized: String = body.split_whitespace().collect::>().join(" "); - assert!( - normalized.starts_with("{ crate::helpers::require_operator(ctx)?;"), - "`realm_channel_op` no longer OPENS with the operator gate. Body was:\n{body}" - ); - } } diff --git a/module/src/chat.rs b/module/src/chat.rs index a2ce0b436..c50ab8cd8 100644 --- a/module/src/chat.rs +++ b/module/src/chat.rs @@ -555,43 +555,6 @@ mod tests { assert!(!is_supported_chat_type(255)); // party/guild/whisper/etc. rejected } - // ---- `apply_send_chat_to`'s type gate (EventAI's only entry into `game_chat_event`) ---- - // - // `apply_send_chat_to` runs inside a reducer and takes no `ReducerContext` mock in this crate, - // so its gate is scanned rather than executed, the same technique as the Realm Chat reducers' - // operator gate scans in `realm_chat.rs`. - - use crate::test_scan::shape_of; - - /// **EventAI's only entry must never admit EMOTE.** - /// - /// `apply_send_chat_to`'s `chat_type` comes straight off an imported - /// `game_creature_ai_broadcast_text` row (`relay.rs`'s `RelayInstruction::Talk`, `engine.rs`'s - /// `eventai_deliver_line`), and Package import never range-checks that column. Before this fix - /// the gate read `!is_supported_chat_type(chat_type) && chat_type != CHAT_EMOTE`, so a broadcast - /// line stamped `chat_type: 3` passed straight through: a `game_chat_event` row with a creature - /// `sender_guid` and no packet the codec can build for it (`build_chat_message_to` has no - /// `(EMOTE, Some(name))` arm), silently rendering as Say. EMOTE is a Character's alone, gated by - /// [`apply_send_chat`]'s own pre-check before this function ever runs. - /// - /// Whole-body equality, not a `contains` scan: a second admitting clause appended anywhere in - /// the function would defeat a substring check but still changes the body this test compares. - #[test] - fn apply_send_chat_to_gates_on_is_supported_chat_type_alone() { - let body = shape_of(include_str!("chat.rs"), "pub(crate) fn apply_send_chat_to("); - let expected = - "{ if !is_supported_chat_type(chat_type) { return Err(format!(\"unsupported \ - chat type {chat_type}\")); } write_chat_event(ctx, sender, target_guid, chat_type, \ - language, message) }"; - assert_eq!( - body, expected, - "`apply_send_chat_to` no longer gates on `is_supported_chat_type` alone — EventAI's \ - only entry must never admit EMOTE (a Character-only type, gated by `apply_send_chat`), \ - or a Package broadcast line with chat_type 3 renders as Say with no packet the codec \ - knows how to build for a creature." - ); - } - /// Wire language values from gtker vanilla `language.rs`: Universal 0, Orcish 1, Common 7. #[test] fn a_player_line_needs_a_language_the_speakers_race_knows() { diff --git a/module/src/combat/death.rs b/module/src/combat/death.rs index e38e1de08..e8661c90b 100644 --- a/module/src/combat/death.rs +++ b/module/src/combat/death.rs @@ -746,8 +746,8 @@ fn award_tag_rewards( /// Roll a dead creature's money + item loot onto its corpse, apply the GROUP loot method's /// need/greed/round-robin/master-loot stamping, and mark it LOOTABLE if anything dropped. Also purges /// any stale corpse-loot residue on this guid BEFORE rolling fresh drops — the fix MUST -/// run on every kill so a leftover pickpocket row can't collide with a freshly-rolled kill-drop slot -/// (see `corpse_residue_tripwire` below, which pins the ordering). Extracted out of `kill_creature`'s +/// run on every kill so a leftover pickpocket row can't collide with a freshly-rolled kill-drop slot. +/// Extracted out of `kill_creature`'s /// inline body so the death sequence reads as a table of contents. fn roll_corpse_loot( ctx: &ReducerContext, @@ -1270,34 +1270,6 @@ mod lethality_tests { let attribution = CreatureDeathAttribution::suicide(77); assert_eq!(attribution.source_guid, Some(77)); assert_eq!(attribution.reward_guid, None); - - let force = crate::test_scan::code_of( - include_str!("death.rs"), - "pub(crate) fn force_creature_death(", - ); - assert!(force.contains("CreatureDeathAttribution::suicide(creature_guid)")); - let death = crate::test_scan::code_of( - include_str!("death.rs"), - "fn kill_creature_with_attribution(", - ); - assert!(death.contains("let killer = attribution.source_guid")); - assert!(death.contains("let reward_source = attribution.reward_guid")); - assert!(death.contains("crate::loot::tag::death_entitlement(")); - assert!(death.contains("killer_guid: killer.unwrap_or(0)")); - } - - #[test] - fn active_duel_lethal_hits_bypass_the_player_death_chokepoint() { - let body = crate::test_scan::code_of(include_str!("death.rs"), "pub(crate) fn apply_hit("); - let duel_floor = body - .find("crate::duel::active_opponents") - .expect("duel finisher is checked in the shared damage seam"); - let player_death = body - .find("kill_player(ctx, target_guid, attacker_guid)") - .expect("ordinary player death remains behind the shared seam"); - assert!(duel_floor < player_death); - assert!(body.contains("target.health = 1;")); - assert!(body.contains("duel_completion_kind::WON")); } #[test] @@ -1307,129 +1279,5 @@ mod lethality_tests { duel_completed: true, }; assert!(outcome.combat_ended()); - - let body = - crate::test_scan::code_of(include_str!("death.rs"), "pub(crate) fn kill_player("); - assert!(body.contains("crate::duel::interrupt_duel_for(ctx, victim_guid)")); - } - - #[test] - fn death_hooks_run_before_engagement_reset_on_player_and_pet_death() { - let src = include_str!("death.rs"); - let creature = crate::test_scan::code_of(src, "fn kill_creature_with_attribution("); - let pet_end = creature - .find("let victim_entry = target.entry") - .expect("pet death stays before ordinary creature death"); - assert_in_order( - &creature[..pet_end], - [ - "begin_death_dispatch", - "disengage(ctx, target_guid)", - "fire_on_death", - "finish_death_dispatch", - ], - ); - - let player = crate::test_scan::code_of(src, "pub(crate) fn kill_player("); - assert_in_order( - &player, - [ - "begin_death_dispatch", - "disengage(ctx, victim_guid)", - "fire_on_death", - "finish_death_dispatch", - ], - ); - } - - #[test] - fn creature_death_carries_the_selected_opponent_across_disengage() { - let body = crate::test_scan::code_of( - include_str!("death.rs"), - "fn kill_creature_with_attribution(", - ); - assert_in_order( - &body, - [ - "let current_target_guid = target.target_guid", - "disengage(ctx, target_guid)", - "current_target_guid,", - ], - ); - } - - #[test] - fn creature_death_keeps_the_loot_tag_through_eventai_dispatch() { - let body = crate::test_scan::code_of( - include_str!("death.rs"), - "fn kill_creature_with_attribution(", - ); - assert_in_order( - &body, - [ - "crate::creatures::tick::stop_where_rendered(", - "crate::loot::tag::death_entitlement(", - "crate::creatures::begin_death_dispatch", - "disengage(ctx, target_guid)", - "crate::hooks::fire_on_creature_death(", - "crate::creatures::finish_death_dispatch", - "crate::loot::tag::clear(ctx, target_guid)", - ], - ); - } - - fn assert_in_order(body: &str, needles: [&str; N]) { - let mut cursor = 0; - for needle in needles { - let offset = body[cursor..] - .find(needle) - .unwrap_or_else(|| panic!("missing `{needle}` in death producer")); - cursor += offset + needle.len(); - } - } -} - -#[cfg(test)] -mod corpse_residue_tripwire { - use crate::test_scan::code_of; - - /// `roll_pickpocket_loot` inserts `game_corpse_loot` rows at slots 0.. on the LIVE - /// creature's guid; if the mob dies before every row is taken, `roll_creature_loot` re-inserts - /// kill drops starting at slot 0 on the SAME guid, producing duplicate `(corpse_guid, slot)` pairs - /// that every first-match loot consumer addresses ambiguously. The fix is a purge that MUST run - /// on every kill, BEFORE the fresh roll — this is `ReducerContext` glue invisible to a behavioural - /// test (no in-process DB harness here), so the wiring is pinned directly, in two parts since - /// Extracted the corpse/loot step out of `kill_creature`'s inline body: - /// `kill_creature` must still route every kill through `roll_corpse_loot`, and `roll_corpse_loot` - /// itself must call `loot::purge_corpse_residue` strictly before `roll_creature_loot`. Losing any - /// of the three pieces silently reopens the collision. - #[test] - fn kill_creature_purges_corpse_residue_before_rolling_fresh_loot() { - let src = include_str!("death.rs"); - let kill_creature_body = code_of(src, "fn kill_creature_with_attribution("); - assert!( - kill_creature_body.contains("roll_corpse_loot(") - && kill_creature_body.contains("entitlement.as_ref()"), - "`kill_creature` no longer routes the kill through `roll_corpse_loot` — the purge-before-\ - fresh-roll ordering below is dead code if this call is gone. Body was:\n{kill_creature_body}" - ); - let body = code_of(src, "fn roll_corpse_loot("); - assert!( - body.contains("crate::loot::purge_corpse_residue(ctx, target_guid)"), - "`roll_corpse_loot` no longer purges corpse residue (game_corpse_loot / \ - game_corpse_loot_eligible) for the dying guid — a leftover pickpocket row can collide \ - with a freshly-rolled kill-drop slot again (issue #358). Body was:\n{body}" - ); - let purge_at = body - .find("crate::loot::purge_corpse_residue(ctx, target_guid)") - .expect("checked above"); - let roll_at = body - .find("crate::loot::roll_creature_loot(ctx, target.entry, target_guid)") - .expect("roll_corpse_loot no longer calls roll_creature_loot with its usual signature"); - assert!( - purge_at < roll_at, - "`purge_corpse_residue` must run BEFORE `roll_creature_loot` — reordering it after \ - lets the fresh kill-drop rows collide with residue that hasn't been cleared yet." - ); } } diff --git a/module/src/combat/engage.rs b/module/src/combat/engage.rs index f1a7dfcc6..168940a7e 100644 --- a/module/src/combat/engage.rs +++ b/module/src/combat/engage.rs @@ -864,25 +864,3 @@ mod duel_relation_tests { assert!(may_help_decision(true, false, true)); } } - -#[cfg(test)] -mod engagement_reset_tripwire { - use crate::test_scan::code_of; - - /// `disengage` is the ONLY place that sees every way a fight ends: an evade, the player dying, - /// a logout, a map change. Each of those clears `IN_COMBAT` here, so the cycle's combat-drop - /// pass never reaches them and its `leave_combat` cannot be the EventAI engagement reset on its - /// own. Dropping this call fails silently and in the player's favour nowhere: the next pull - /// finds once-only aggro rules still spent and timed rules due on their first tick. - #[test] - fn freeing_a_unit_starts_its_next_eventai_engagement() { - let body = code_of( - include_str!("engage.rs"), - "pub(crate) fn disengage(ctx: &ReducerContext, guid: u64) {", - ); - assert!( - body.contains("crate::creatures::reset_engagement(ctx, g)"), - "`disengage` no longer resets the EventAI engagement of the units it freed" - ); - } -} diff --git a/module/src/combat/swing.rs b/module/src/combat/swing.rs index 47093a0aa..15260b862 100644 --- a/module/src/combat/swing.rs +++ b/module/src/combat/swing.rs @@ -1009,208 +1009,10 @@ pub fn ranged_impact(ctx: &ReducerContext, shot: RangedImpactSchedule) { #[cfg(test)] mod damage_pipeline_drift_tests { - // Found TWO live divergences in what was then a copy-pasted post-roll damage pipeline — - // off-hand swings ignored Disarm, ranged impacts ignored godmode — and pinned each fix in place. - // Removed the copies: there is now ONE pipeline (`fold_incoming_damage` → `final_damage` - // → `apply_hit`) that - // every damaging path routes through, so the drift class those two tests guard against can no - // longer be introduced one resolver at a time. These tests therefore moved UP a level: instead of - // pinning each fix in each copy, they pin that every resolver still goes through the one - // chokepoint (plus the two guards that remain genuinely resolver-local). - // - // There is no `ReducerContext` harness in this crate by design (see `test_scan`'s doc comment / - // playbook §7), so — same as the other chokepoint tests in this file — these pin the wiring's - // PRESENCE in the reducers' own source text. They prove the call is there, not that the pipeline - // behaves correctly in isolation; the pure pieces (`is_lethal`, the rage/armor/attack-table math) - // are covered by their own direct tests. Every scan goes through `code_of`, never `body_of`, so a - // needle planted in a comment can't satisfy it. - use crate::test_scan::code_of; - - /// Every resolver that applies damage, keyed by the source file it lives in. - const RESOLVERS: [(&str, &str); 4] = [ - ("combat/swing.rs", "fn fire_melee_swing("), - ("combat/swing.rs", "fn resolve_offhand_swing("), - ("combat/swing.rs", "pub fn ranged_impact("), - ("spell/effects.rs", "pub(crate) fn apply_target_damage("), - ]; - - fn source(file: &str) -> &'static str { - match file { - "combat/swing.rs" => include_str!("swing.rs"), - "spell/effects.rs" => include_str!("../spell/effects.rs"), - other => panic!("no source registered for `{other}`"), - } - } - #[test] fn seal_projection_sums_to_the_floored_final_damage() { assert_eq!(super::split_damage_projection(0, 5), (0, 0)); assert_eq!(super::split_damage_projection(3, 5), (0, 3)); assert_eq!(super::split_damage_projection(9, 5), (4, 5)); } - - #[test] - fn ranged_damage_projection_is_emitted_from_the_impact_decision() { - let launch = code_of(source("combat/swing.rs"), "fn fire_ranged_shot("); - assert!( - launch.contains("damage: 0"), - "launch emitted a stale damage log" - ); - assert!( - !launch.contains("final_damage("), - "launch resolved the lethal floor before impact" - ); - - let impact = code_of(source("combat/swing.rs"), "pub fn ranged_impact("); - assert!(impact.contains("let damage = final_damage(")); - assert!(impact.contains("damage: damage.amount")); - assert!(impact.contains("is_proc_log: true")); - } - - #[test] - fn every_damage_resolver_routes_through_apply_hit() { - for (file, sig) in RESOLVERS { - let body = code_of(source(file), sig); - assert!( - body.contains("final_damage("), - "`{sig}` in {file} no longer applies the EventAI lethal floor after mitigation. \ - Body was:\n{body}" - ); - assert!( - body.contains("apply_hit("), - "`{sig}` in {file} no longer applies its damage through the shared `apply_hit` \ - pipeline. That pipeline (rage both ways, skill-ups, the lethal fork through \ - kill_player/kill_creature, the health write, break-on-damage, threat) exists exactly \ - once precisely because hand-maintained copies of it drifted twice (#361). Body \ - was:\n{body}" - ); - } - } - - /// The source argument is not decoration: it is the combat EVENT the Proc engine fires off, and a - /// resolver that named the wrong one would fire the wrong procs (an off-hand swing that claimed to - /// be a main-hand one would never feed an off-hand-only proc). `apply_target_damage` is the odd one - /// out on purpose — it takes the hit from its caller and must forward it verbatim, never invent one. - #[test] - fn each_weapon_resolver_names_the_proc_event_its_hit_raises() { - for (sig, event) in [ - ("fn fire_melee_swing(", "HitSource::MainHand"), - ("fn resolve_offhand_swing(", "HitSource::OffHand"), - ("pub fn ranged_impact(", "HitSource::Ranged"), - ] { - let body = code_of(source("combat/swing.rs"), sig); - assert!( - body.contains(event), - "`{sig}` no longer names `{event}` on its hit — the Proc engine fires the combat \ - event this argument selects. Body was:\n{body}" - ); - } - let spell = code_of( - source("spell/effects.rs"), - "pub(crate) fn apply_target_damage(", - ); - assert!( - spell.contains("apply_hit(ctx, caster_guid, target_guid, damage, hit)"), - "`apply_target_damage` must forward its caller's hit verbatim — manufacturing one here \ - would relabel every spell effect's proc event. Body was:\n{spell}" - ); - } - - #[test] - fn no_resolver_reaches_the_kill_chokepoints_behind_apply_hit() { - for (file, sig) in RESOLVERS { - let body = code_of(source(file), sig); - for direct in ["kill_creature(", "kill_player("] { - assert!( - !body.contains(direct), - "`{sig}` in {file} calls `{direct}` directly again. The lethal fork — including \ - WHO gets kill credit (a pet credits its owner) and the spell path's \ - floor-a-player-at-1-hp exception — belongs to `apply_hit` alone; a second copy \ - of it is how the credit rules drift apart. Body was:\n{body}" - ); - } - } - } - - #[test] - fn every_freshly_rolled_hit_folds_through_the_shared_modifier_chain() { - // The ranged IMPACT is deliberately absent: its damage was folded (and frozen) at launch by - // `fire_ranged_shot`, which is in this list. Everything that rolls a NEW number folds it here. - for (file, sig) in [ - ("combat/swing.rs", "fn fire_melee_swing("), - ("combat/swing.rs", "fn resolve_offhand_swing("), - ("combat/swing.rs", "fn fire_ranged_shot("), - ("spell/effects.rs", "pub(crate) fn apply_target_damage("), - ] { - let body = code_of(source(file), sig); - assert!( - body.contains("fold_incoming_damage("), - "`{sig}` in {file} no longer folds its rolled damage through \ - `fold_incoming_damage` — the outgoing %, damage-taken %, absorb and godmode chain. \ - Re-inlining any of those four steps re-creates the copy that drifted in #361. Body \ - was:\n{body}" - ); - assert!( - !body.contains("absorb_incoming("), - "`{sig}` in {file} folds absorb itself again instead of leaving it to \ - `fold_incoming_damage`. Body was:\n{body}" - ); - } - } - - #[test] - fn offhand_swing_checks_disarm_before_rolling_its_range() { - // Still resolver-local: Disarm strips the MAIN hand inside `swing_range_ctx`, and the - // off-hand derives its own range, so the gate has to be read here too. - let body = code_of(include_str!("swing.rs"), "fn resolve_offhand_swing("); - assert!( - body.contains("is_disarmed"), - "resolve_offhand_swing no longer reads is_disarmed — a Disarmed dual-wielder would keep \ - swinging the off-hand at full value while swing_range_ctx strips the main hand. Body \ - was:\n{body}" - ); - } - - #[test] - fn ranged_impact_re_checks_godmode_at_impact_time() { - // Still resolver-local: the rest of the modifier chain is frozen at LAUNCH, so this is - // the one fold the impact must re-evaluate (godmode can be toggled during the arrow's flight). - let body = code_of(include_str!("swing.rs"), "pub fn ranged_impact("); - assert!( - body.contains("target.godmode"), - "ranged_impact no longer re-checks the target's godmode flag at impact — a delayed arrow \ - would land damage on a target that toggled godmode on mid-flight, which a melee swing at \ - the same instant would zero. Body was:\n{body}" - ); - } - - #[test] - fn the_ranged_teardown_rule_is_written_once() { - // The "a blocker on a DUE ranged shot INTERRUPTS the auto-repeat loop" teardown used to - // be repeated at each of the four positional gates (range / minimum range / LoS / facing). - // Those gates now live in the side-effect-free `swing_blocked`, so `resolve_swing` states the - // teardown once. Pinned by absence: the gates' own predicates must not reappear inline. - let body = code_of(include_str!("swing.rs"), "fn resolve_swing("); - for gate in [ - "has_los(", - "is_facing(", - "RANGED_RANGE_SQ", - "MELEE_RANGE_LEEWAY_SQ", - ] { - assert!( - !body.contains(gate), - "`resolve_swing` inlines the positional gate `{gate}` again instead of delegating to \ - `swing_blocked`. Each inlined gate needs its own `if ranged_due {{ delete }}` \ - teardown, and that is exactly the repetition #370 removed. Body was:\n{body}" - ); - } - assert_eq!( - body.matches("if ranged_due").count(), - 2, - "`resolve_swing` should branch on `ranged_due` exactly twice: once to DEFER a moving \ - shooter's shot (the Auto Shot re-arm) and once for the positional gate's teardown. A \ - third occurrence means a gate got inlined again, each inline copy bringing its own \ - teardown back with it. Body was:\n{body}" - ); - } } diff --git a/module/src/config.rs b/module/src/config.rs index bb61e7d93..f005ae2a4 100644 --- a/module/src/config.rs +++ b/module/src/config.rs @@ -361,63 +361,3 @@ mod realm_address_tests { ); } } - -#[cfg(test)] -mod set_realm_address_is_operator_gated_tripwire { - use crate::test_scan::code_of; - - /// No `ReducerContext` harness exists in this crate, so the gate's PRESENCE is pinned by a scan. - /// Without it any client could redirect every player on the realm to a host it chose. - #[test] - fn set_realm_address_requires_the_operator_identity_first() { - let src = include_str!("config.rs"); - let body = code_of( - src, - "pub fn set_realm_address(ctx: &ReducerContext, address: String) -> Result<(), String> {", - ); - assert!( - body.contains("require_operator(ctx)?;"), - "`set_realm_address` is no longer operator-gated — any caller could point the realm \ - list at a host of their choosing. Body was:\n{body}" - ); - } - - /// A refused address must leave the row alone. Validation is what refuses, so it has to run - /// before the write — a validate-after-update would blank the realm list and then report an - /// error, which is the one outcome worse than refusing. - #[test] - fn a_refused_address_never_reaches_the_row() { - let src = include_str!("config.rs"); - let body = code_of( - src, - "pub fn set_realm_address(ctx: &ReducerContext, address: String) -> Result<(), String> {", - ); - let validate = body - .find("validate_realm_address(&address)?;") - .expect("set_realm_address still validates"); - let write = body - .find("realms.id().update(realm);") - .expect("set_realm_address still writes the row"); - assert!( - validate < write, - "`set_realm_address` now writes the row before validating it, so a refused address \ - lands anyway. Body was:\n{body}" - ); - } - - /// The write is the only record that the realm moved; without it an unexpected change is - /// unattributable. - #[test] - fn an_accepted_address_is_logged_with_both_values() { - let src = include_str!("config.rs"); - let body = code_of( - src, - "pub fn set_realm_address(ctx: &ReducerContext, address: String) -> Result<(), String> {", - ); - assert!( - body.contains("log::info!(\"set_realm_address: {previous} -> {address}\");"), - "`set_realm_address` no longer logs the change, so a realm redirected by an operator \ - leaves no record of what it was. Body was:\n{body}" - ); - } -} diff --git a/module/src/creatures/cycle/ctx.rs b/module/src/creatures/cycle/ctx.rs index 314007f20..b16dbcc3a 100644 --- a/module/src/creatures/cycle/ctx.rs +++ b/module/src/creatures/cycle/ctx.rs @@ -2,8 +2,7 @@ //! //! **This layer is the seam's own blind spot.** The harness substitutes an in-memory `Scenario` for //! every line of it, so a no-op'd method here is invisible to every test in the crate. Keep every -//! method a pass-through: `harness::the_production_adapter_is_the_pass_through_the_harness_assumes` -//! pins this file's exact shape, and re-blessing an edit there is the only thing that covers it. +//! method a pass-through. use std::collections::HashSet; diff --git a/module/src/creatures/cycle/harness.rs b/module/src/creatures/cycle/harness.rs index 81cb1ee7a..6bb00c352 100644 --- a/module/src/creatures/cycle/harness.rs +++ b/module/src/creatures/cycle/harness.rs @@ -5915,476 +5915,3 @@ fn discovery_stays_on_the_narrow_candidate_universes() { count that reaches it is a candidate set that went back to scanning everything" ); } - -// ================================================================================================ -// THE PRODUCTION ADAPTER'S SHAPE — the seam's own blind spot -// ================================================================================================ - -/// `Scenario` above stands in for EVERY line of `ctx.rs`, so a method there that quietly stops -/// doing what it says leaves this whole file green while a live shard's creatures freeze. Nothing -/// else can reach that layer: no headless test in this crate can execute a `ReducerContext`, and -/// cargo-mutants cannot either. So the instrument is exact text — equality, never `contains`, which -/// a leftover copy in a dead branch defeats. -/// -/// What a silent edit here costs, method by method: `commit_position` no-op'd and every creature -/// stands still while the client animates on; `drop_leg` no-op'd and one leg replays forever; -/// `engage` no-op'd and nothing ever aggroes; `restore` no-op'd and health never comes back; -/// `awake_creatures` returning an empty sweep and the world goes dormant with every test passing; -/// `settle_advances` skipping its gate and every stored creature position stays at its leg start; -/// `stop_on_stored_leg` skipping the obstruction check and a stop lands past a blocked segment. -/// -/// Several methods are deliberately more than one expression — `place`, `engage`, `retarget`, -/// `combat_healed_to`, `restore`, `face` and `take_victim` — so the pin is the exact current body -/// rather than a "one expression" rule. `retarget` most of all: it updates the engagement row IN -/// PLACE, which is what carries the swing clock across a threat switch, and only this assertion -/// says so. Re-bless a deliberate change here with the same care. -#[test] -#[allow(clippy::too_many_lines)] // One assertion per method the production adapter passes through. -fn the_production_adapter_is_the_pass_through_the_harness_assumes() { - let src = include_str!("ctx.rs"); - for (signature, want) in [ - ( - "pub(crate) fn run(ctx: &ReducerContext, tick: TickContext) -> CycleOutcome {", - concat!( - "{ let regen_window = crate::combat::RegenWindow::new( tick.now_micros / 1_000, ", - "(f64::from(tick.sense_secs) * 1_000.0).round() as u64, ); let mut world = CtxWorld { ", - "ctx, regen_window, advances: Vec::new(), }; let outcome = run_cycle(&mut world, ", - "tick); world.settle_advances(); outcome }", - ), - ), - ( - "struct CtxWorld<'a> {", - concat!( - "{ ctx: &'a ReducerContext, regen_window: crate::combat::RegenWindow, advances: ", - "Vec<(WorldEntity, WorldEntity)>, }", - ), - ), - ( - "impl CtxWorld<'_> {", - concat!( - "{ fn settle_advances(self) { let entities = self.ctx.db.game_world_entity(); let ", - "now_micros = self.ctx.timestamp.to_micros_since_unix_epoch() as u64; for (stored, ", - "advanced) in self.advances { let Some(settled) = entities.guid().find(stored.guid) ", - "else { continue; }; 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); } } } ", - "fn place( &self, guid: u64, at: Point, moved_ms: Option, orientation: ", - "Option, ) -> Option { let entities = ", - "self.ctx.db.game_world_entity(); let mut e = entities.guid().find(guid)?; let ", - "(gx, gy) = spatial::grid_cell(at.x, at.y); e.x = at.x; e.y = at.y; e.z = at.z; ", - "e.grid_x = gx; e.grid_y = gy; e.cell = spatial::grid_cell_id(gx, gy); if let ", - "Some(ms) = moved_ms { e.last_move_ms = ms; } if let Some(rad) = orientation { ", - "e.orientation = rad; } Some(entities.guid().update(e)) } }", - ), - ), - ( - "pub(crate) fn stop_on_stored_leg(ctx: &ReducerContext, leg: CreatureSpline) -> Option {", - concat!( - "{ let now_micros = ctx.timestamp.to_micros_since_unix_epoch() as u64; let world = ", - "CtxWorld { ctx, regen_window: crate::combat::RegenWindow::new(now_micros / 1_000, 0), ", - "advances: Vec::new(), }; super::stop_on_leg(&world, &as_leg(leg, false), now_micros) }", - ), - ), - ( - "fn as_leg(s: CreatureSpline, mover_gone: bool) -> LegInFlight {", - concat!( - "{ LegInFlight { guid: s.guid, start: Point { x: s.sx, y: s.sy, z: s.sz, }, dest: Point { ", - "x: s.dx, y: s.dy, z: s.dz, }, started_micros: s.start_micros, dur_ms: s.dur_ms, map_id: ", - "s.map_id, instance_id: s.instance_id, mover_gone, waypoints: s .path .map(|path| ", - "path.points) .unwrap_or_default() .iter() .map(|p| (p.x, p.y, p.z)) .collect(), } }", - ), - ), - ( - "fn translation_flags(unit: &WorldEntity, leg: Option) -> u32 {", - concat!( - "{ let carried = leg.is_some_and(|l| l.dur_ms > 0); unit.movement_flags | if ", - "carried { MOVE_FLAG_FORWARD } else { 0 } }", - ), - ), - ( - "fn caster_hold_range_yd(ctx: &ReducerContext, entry: u32) -> f32 {", - concat!( - "{ let spells = ctx.db.game_spell(); let mut max_r = 0u32; for r in ", - "ctx.db.game_creature_spell().by_entry().filter(&entry) { if matches!( ", - "r.condition, cast_condition::ALWAYS | cast_condition::TARGET_MISSING_AURA ) { ", - "if let Some(h) = spells.spell_id().find(r.spell_id) { max_r = ", - "max_r.max(h.range_yd); } } } if let Some(c) = ", - "ctx.db.game_creature_cast().creature_entry().find(entry) { if let Some(h) = ", - "spells.spell_id().find(c.spell_id) { max_r = max_r.max(h.range_yd); } } max_r ", - "as f32 }", - ), - ), - ( - "impl MotionSink for CtxWorld<'_> {", - concat!( - "{ fn path_obstruction(&self, leg: &LegInFlight, now_micros: u64) -> Option { if ", - "leg.waypoints.is_empty() { return None; } let mover = ", - "self.ctx.db.game_world_entity().guid().find(leg.guid)?; let current = ", - "self.ctx.db.game_creature_spline().guid().find(leg.guid)?; if current .path ", - ".is_some_and(|path| path.navigation != crate::nav::inputs(self.ctx, leg.map_id)) { return ", - "Some(Point { x: mover.x, y: mover.y, z: mover.z, }); } let now_ms = (now_micros / 1000) as ", - "u32; let elapsed = u64::from(now_ms.wrapping_sub(mover.last_move_ms)) * 1000; let ", - "previous_micros = now_micros.saturating_sub(elapsed); let start = (leg.start.x, ", - "leg.start.y, leg.start.z); let (_, first) = lyracore_shared::movement_path::sample( start, ", - "&leg.waypoints, super::spline_t(previous_micros, leg.started_micros, leg.dur_ms), ); let ", - "(at, last) = lyracore_shared::movement_path::sample( start, &leg.waypoints, ", - "super::spline_t(now_micros, leg.started_micros, leg.dur_ms), ); let mut from = (mover.x, ", - "mover.y, mover.z); for to in leg.waypoints[first.min(last)..last] .iter() .copied() ", - ".chain([at]) { if !crate::nav::route_segment_clear(self.ctx, leg.map_id, leg.instance_id, ", - "from, to) { return Some(Point { x: mover.x, y: mover.y, z: mover.z, }); } from = to; } ", - "None } fn legs_in_flight(&self) -> 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() } fn ", - "movement_suppressed(&self, guid: u64) -> bool { ", - "crate::spell::is_self_movement_suppressed(self.ctx, guid) || ", - "eventai::movement::intent(self.ctx, guid).is_some_and(|intent| intent.immobilized) } fn ", - "commit_position(&mut self, guid: u64, at: Point, moved_ms: u32) { let heading = self .ctx ", - ".db .game_creature_spline() .guid() .find(guid) .and_then(|s| { as_leg(s, false) ", - ".heading_on_path(at, self.ctx.timestamp.to_micros_since_unix_epoch() as u64) }); ", - "let stored = ", - "self.ctx.db.game_world_entity().guid().find(guid); if let (Some(stored), ", - "Some(advanced)) = (stored, self.place(guid, at, Some(moved_ms), heading)) { ", - "self.advances.push((stored, advanced)); } } fn halt(&mut self, leg: &LegInFlight, at: Point, spline_id: ", - "u32) { if let Some(e) = self.place(leg.guid, at, None, None) { tick::emit_move_spline( ", - "self.ctx, leg.guid, (at.x, at.y, at.z), (at.x, at.y, at.z), 0, false, spline_id, ", - "leg.map_id, leg.instance_id, (e.grid_x, e.grid_y), ); } } fn drop_leg(&mut self, guid: ", - "u64) { self.ctx.db.game_creature_spline().guid().delete(guid); } }", - ), - ), - ( - "impl IdleSink for CtxWorld<'_> {", - concat!( - "{ fn idle_creatures(&self, active: &HashSet) -> Vec { let ", - "entities = self.ctx.db.game_world_entity(); let waypoints = ", - "self.ctx.db.game_creature_waypoint(); active .iter() .filter_map(|guid| ", - "entities.guid().find(guid)) .filter(|c| !c.is_player() && !c.dead) .map(|c| ", - "IdleCreature { guid: c.guid, at: Point { x: c.x, y: c.y, z: c.z, }, ", - "leg_ends_ms: c.leg_ends_ms, wp_target: c.wp_target, patrols: ", - "waypoints.by_creature().filter(&c.guid).next().is_some(), }) .collect() } fn ", - "route_of(&self, guid: u64) -> Vec { self.ctx .db ", - ".game_creature_waypoint() .by_creature() .filter(&guid) .map(|w| Waypoint { ", - "id: w.id, at: Point { x: w.x, y: w.y, z: w.z, }, }) .collect() } fn ", - "home_of(&self, guid: u64) -> Option { self.ctx .db ", - ".game_creature_spawn() .guid() .find(guid) .map(|s| Home { at: Point { x: s.x, ", - "y: s.y, z: s.z, }, wanders: s.movement_type == ", - "crate::creatures::MOVEMENT_RANDOM, }) } fn engaged(&self, guid: u64) -> bool { ", - "crate::combat::is_engaged(self.ctx, guid) } fn speed_of(&self, guid: u64, ", - "gait: Gait) -> f32 { crate::combat::effective_move_speed( self.ctx, guid, ", - "match gait { Gait::Walk => constants::speeds::WALK, Gait::Run => ", - "constants::speeds::RUN, }, ) } fn navigate(&self, guid: u64, to: (f32, f32), ", - "max_step: f32) -> (f32, f32) { self.ctx .db .game_world_entity() .guid() ", - ".find(guid) .map_or(to, |c| { crate::nav::nav_step(self.ctx, c.map_id, c.instance_id, (c.x, ", - "c.y), to, max_step, 0.0, c.z) }) } fn roll(&self) -> u32 { self.ctx.random() } ", - "fn aim_at_waypoint(&mut self, guid: u64, waypoint_id: u64) { 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); } } fn reached_home(&mut self, guid: u64) { ", - "crate::creatures::eventai_on_reached_home(self.ctx, guid); } fn ", - "commit_leg(&mut self, guid: u64, leg: Leg, ", - "now_ms: u32) { if let Some(e) = ", - "self.ctx.db.game_world_entity().guid().find(guid) { tick::emit_creature_leg( ", - "self.ctx, e, leg.to, leg.z_fallback, leg.dur_ms, leg.gait == Gait::Run, ", - "now_ms, leg.hold_until_landed, ); } } }", - ), - ), - ( - "impl EngageSink for CtxWorld<'_> {", - concat!( - "{ fn players(&self) -> Vec { self.ctx .db .game_world_entity() ", - ".iter() .filter(|e| e.is_player()) .map(|e| AggroTarget { guid: e.guid, at: ", - "Point { x: e.x, y: e.y, z: e.z, }, level: e.level, faction_template: ", - "e.faction_template, map_id: e.map_id, instance_id: e.instance_id, dead: ", - "e.dead, godmode: e.godmode, stealthed: crate::spell::is_stealthed(self.ctx, ", - "e.guid), }) .collect() } fn sensing_creatures(&self, active: &HashSet) -> ", - "Vec { let entities = self.ctx.db.game_world_entity(); let melee = ", - "self.ctx.db.game_melee_attack(); let templates = ", - "self.ctx.db.game_creature_template(); active .iter() .filter_map(|guid| ", - "entities.guid().find(guid)) .filter(|c| { !c.is_player() && !c.dead && ", - "c.owner_guid == 0 && melee.attacker_guid().find(c.guid).is_none() }) .map(|c| ", - "Sensor { guid: c.guid, at: Point { x: c.x, y: c.y, z: c.z, }, level: c.level, ", - "faction_template: c.faction_template, map_id: c.map_id, instance_id: ", - "c.instance_id, aggro_range: templates.entry().find(c.entry).map(|t| ", - "t.aggro_range), detect_range_mod: crate::spell::detect_range_mod(self.ctx, ", - "c.guid), would_rout: tick::rout_eligible(self.ctx, &c), cannot_act: ", - "crate::spell::is_action_blocked(self.ctx, c.guid), react_state: ", - "crate::creatures::react_state(self.ctx, c.guid), }) .collect() } fn ", - "hostile(&self, faction_template: u32, other: u32) -> bool { ", - "crate::faction::is_hostile(self.ctx, faction_template, other) } fn ", - "line_of_sight(&self, looker: u64, at: Point) -> bool { self.ctx .db ", - ".game_world_entity() .guid() .find(looker) .is_none_or(|c| { ", - "crate::nav::has_los( self.ctx, c.map_id, c.instance_id, (c.x, c.y, c.z), (at.x, at.y, at.z), ) ", - "}) } fn engage(&mut self, creature: u64, victim: u64, pull: Pull) { ", - "crate::combat::arm_creature_engagement(self.ctx, creature, victim, pull == ", - "Pull::Assisted); } fn engagements(&self) -> ", - "Vec { let entities = self.ctx.db.game_world_entity(); self.ctx .db ", - ".game_melee_attack() .iter() .filter_map(|a| { let attacker = ", - "entities.guid().find(a.attacker_guid); let instance_id = attacker .as_ref() ", - ".map(|e| e.instance_id) .or_else(|| ", - "entities.guid().find(a.target_guid).map(|e| e.instance_id))?; Some(Engagement ", - "{ attacker: a.attacker_guid, victim: a.target_guid, instance_id, ", - "player_never_swung: a.last_swing_ms == 0 && a.last_offhand_swing_ms == 0 && ", - "attacker.is_some_and(|e| e.is_player()), }) }) .collect() } fn ", - "enter_combat(&mut self, guid: u64) { crate::combat::enter_combat(self.ctx, ", - "guid); } fn flagged_in_combat(&self, candidates: &[u64]) -> Vec { ", - "let entities = self.ctx.db.game_world_entity(); candidates .iter() ", - ".filter_map(|guid| entities.guid().find(guid)) .filter(|e| e.unit_flags & ", - "constants::unit_flags::IN_COMBAT != 0) .map(|e| Combatant { guid: e.guid, ", - "combat_until_ms: e.combat_until_ms, }) .collect() } fn leave_combat(&mut self, ", - "guid: u64) { crate::creatures::eventai::reset_engagement(self.ctx, guid); 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); } } }", - ), - ), - ( - "impl RegenSink for CtxWorld<'_> {", - concat!( - "{ fn recovering(&self, flagged: &[u64]) -> Vec { let mut in_combat ", - "= crate::combat::melee_combatant_guids(self.ctx); ", - "in_combat.extend_from_slice(flagged); self.ctx .db .game_world_entity() ", - ".iter() .filter(|e| !e.dead && (e.health < e.max_health || e.max_power > 0)) ", - ".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), }) .collect() } fn healed_to(&self, u: ", - "&Recovering) -> u32 { self.ctx .db .game_world_entity() .guid() .find(u.guid) ", - ".map_or(u.health, |e| { let health_per_five = ", - "crate::items::equipped_stat_bonus( self.ctx, e.guid, ", - "crate::items::EquipStat::HealthPerFive, ); ", - "crate::combat::regen_entity_health(&e, health_per_five, self.regen_window) ", - "}) } fn ", - "combat_healed_to(&self, u: &Recovering) -> Option { let pct = ", - "u32::try_from(crate::spell::combat_health_regen_pct(self.ctx, u.guid))", - ".unwrap_or(0); let e = self.ctx.db.game_world_entity().guid().find(u.guid)?; ", - "let health_per_five = crate::items::equipped_stat_bonus( self.ctx, e.guid, ", - "crate::items::EquipStat::HealthPerFive, ); (pct > 0 || health_per_five != 0)", - ".then(|| { crate::combat::regen_entity_health_in_combat( &e, pct, ", - "health_per_five, self.regen_window, ) }) } fn powered_to(&self, u: ", - "&Recovering) -> u32 { self.ctx .db .game_world_entity() .guid() .find(u.guid) ", - ".map_or(u.power, |e| { ", - "let mana_per_five = crate::items::equipped_stat_bonus( self.ctx, e.guid, ", - "crate::items::EquipStat::ManaPerFive, ); ", - "crate::combat::regen_entity_power(&e, u.in_combat, self.regen_window, ", - "mana_per_five) }) } fn ", - "restore(&mut self, guid: u64, health: Option, power: Option) { let ", - "entities = self.ctx.db.game_world_entity(); let Some(mut live) = ", - "entities.guid().find(guid) else { return; }; let (next_health, next_power) = ", - "(health.unwrap_or(live.health), power.unwrap_or(live.power)); if (next_health, ", - "next_power) == (live.health, live.power) { return; } live.health = ", - "next_health; live.power = next_power; entities.guid().update(live); } }", - ), - ), - ( - "fn entry_of(&self, guid: u64) -> Option {", - "{ self.ctx .db .game_world_entity() .guid() .find(guid) .map(|c| c.entry) }", - ), - ( - "impl CastSink for CtxWorld<'_> {", - concat!( - "{ fn casters(&self, scope: &TickScope) -> Vec { let entities = ", - "self.ctx.db.game_world_entity(); self.ctx .db .game_melee_attack() .iter() ", - ".filter_map(|row| { let c = tick::movable_creature(self.ctx, ", - "row.attacker_guid, scope)?; let guid = c.guid; let casting = ", - "crate::spell::is_non_melee_spell_casting(self.ctx, guid); Some(Caster { guid, ", - "victim: row.target_guid, victim_at: ", - "entities.guid().find(row.target_guid).map(|t| Point { x: t.x, y: t.y, z: t.z, ", - "}), level: c.level as u8, health: c.health, max_health: c.max_health, ", - "cannot_act: crate::spell::is_action_blocked(self.ctx, guid), casting, }) }) ", - ".collect() } fn rotation_of(&self, guid: u64) -> Vec { if ", - "eventai::authored_combat(self.ctx, guid).casting { return Vec::new(); } ", - "self.entry_of(guid).map_or(Vec::new(), |entry| { self.ctx .db ", - ".game_creature_spell() .by_entry() .filter(&entry) .map(|r| SpellOption { ", - "spell_id: r.spell_id, when: CastWhen::of(r.condition, r.condition_value), ", - "priority: r.priority, authored: r.id, }) .collect() }) } fn lone_spell(&self, ", - "guid: u64) -> Option { if eventai::authored_combat(self.ctx, guid).casting { ", - "return None; } self.ctx .db .game_creature_cast() ", - ".creature_entry() .find(self.entry_of(guid)?) .map(|c| c.spell_id) } fn ", - "carries(&self, guid: u64, spell_id: u32) -> bool { ", - "crate::spell::has_aura(self.ctx, guid, spell_id) } fn begin_cast(&mut self, ", - "caster: &Caster, spell_id: u32, target: u64) -> bool { ", - "crate::spell::begin_cast( self.ctx, caster.guid, spell_id, caster.level, ", - "target, false, None, ) .is_ok() } }", - ), - ), - ( - "impl ThreatSink for CtxWorld<'_> {", - concat!( - "{ fn fighters(&self, scope: &TickScope) -> Vec { self.ctx .db ", - ".game_melee_attack() .iter() .filter_map(|a| { ", - "tick::movable_creature(self.ctx, a.attacker_guid, scope).map(|c| Fighter { ", - "guid: c.guid, victim: a.target_guid, }) }) .collect() } fn taunted_onto(&self, ", - "guid: u64) -> Option { crate::threat::forced_target( self.ctx, guid, ", - "(self.ctx.timestamp.to_micros_since_unix_epoch() / 1000) as u64, ) } fn ", - "top_threat(&self, guid: u64) -> Option { ", - "crate::threat::top_threat_target(self.ctx, guid) } fn threat_on(&self, ", - "creature: u64, source: u64) -> i64 { crate::threat::threat_of(self.ctx, ", - "creature, source) } fn retarget(&mut self, creature: u64, victim: u64) { let ", - "melee = self.ctx.db.game_melee_attack(); if let Some(mut row) = ", - "melee.attacker_guid().find(creature) { row.target_guid = victim; ", - "melee.attacker_guid().update(row); } let entities = ", - "self.ctx.db.game_world_entity(); if let Some(mut c) = ", - "entities.guid().find(creature) { if c.target_guid != victim { c.target_guid = ", - "victim; entities.guid().update(c); } } } }", - ), - ), - ( - "impl PursuitSink for CtxWorld<'_> {", - concat!( - "{ fn pursuits(&self, scope: &TickScope) -> Vec { let entities = ", - "self.ctx.db.game_world_entity(); let splines = ", - "self.ctx.db.game_creature_spline(); self.ctx .db .game_melee_attack() .iter() ", - ".filter_map(|row| { let c = tick::movable_creature(self.ctx, ", - "row.attacker_guid, scope)?; let victim = ", - "entities.guid().find(row.target_guid)?; Some(Pursuit { guid: c.guid, at: Point ", - "{ x: c.x, y: c.y, z: c.z, }, orientation: c.orientation, victim_at: Point { x: ", - "victim.x, y: victim.y, z: victim.z, }, victim_orientation: ", - "victim.orientation, victim_movement_flags: ", - "translation_flags( &victim, splines.guid().find(row.target_guid), ), routing: ", - "tick::creature_is_routing(self.ctx, &c), leg: ", - "splines.guid().find(c.guid).map(|s| as_leg(s, false)), }) }) .collect() } fn ", - "authored_ranged_posture(&self, guid: u64) -> Option<(f32, f32)> { ", - "eventai::ranged_posture(self.ctx, guid) } fn ", - "caster_hold_range(&self, guid: u64) -> f32 { if ", - "eventai::authored_combat(self.ctx, guid).casting { return 0.0; } self.ctx .db ", - ".game_world_entity() .guid() .find(guid) .map_or(0.0, |c| ", - "caster_hold_range_yd(self.ctx, c.entry)) ", - "} fn face(&mut self, guid: u64, at: Point, orientation: f32, spline_id: u32) { ", - "let Some(e) = self.place(guid, at, None, Some(orientation)) else { return; }; ", - "tick::emit_facing_spline( self.ctx, guid, (at.x, at.y, at.z), orientation, ", - "spline_id, e.map_id, e.instance_id, (e.grid_x, e.grid_y), ); } }", - ), - ), - ( - "impl RoutSink for CtxWorld<'_> {", - concat!( - "{ fn routers(&self, scope: &TickScope) -> Vec { let entities = ", - "self.ctx.db.game_world_entity(); let splines = ", - "self.ctx.db.game_creature_spline(); self.ctx .db .game_melee_attack() .iter() ", - ".filter_map(|row| { let c = tick::movable_creature(self.ctx, ", - "row.attacker_guid, scope)?; Some(Router { guid: c.guid, at: Point { x: c.x, y: ", - "c.y, z: c.z, }, victim: row.target_guid, victim_at: ", - "entities.guid().find(row.target_guid).map(|t| Point { x: t.x, y: t.y, z: t.z, ", - "}), health: c.health, max_health: c.max_health, eligible: ", - "tick::rout_eligible(self.ctx, &c) && !eventai::authored_combat(self.ctx, ", - "c.guid).flee, rout_ends_ms: row.rout_ends_ms, routing: ", - "tick::creature_is_routing(self.ctx, &c), committed: ", - "splines.guid().find(c.guid).is_some(), }) }) .collect() } fn start_rout(&mut ", - "self, guid: u64, ends_ms: u32) { let melee = self.ctx.db.game_melee_attack(); ", - "if let Some(mut row) = melee.attacker_guid().find(guid) { row.rout_ends_ms = ", - "ends_ms; melee.attacker_guid().update(row); } } }", - ), - ), - ( - "impl FearSink for CtxWorld<'_> {", - concat!( - "{ fn panicked(&self, scope: &TickScope) -> Vec { let entities = ", - "self.ctx.db.game_world_entity(); let melee = self.ctx.db.game_melee_attack(); ", - "self.ctx .db .game_aura() .by_kind_param() .filter((crate::spell::A_CONTROL, ", - "crate::spell::M_FEAR)) .filter_map(|a| { ", - "let c = tick::movable_creature(self.ctx, a.target_guid, scope)?; let source = ", - "crate::spell::fear_source(self.ctx, c.guid)?; let at = ", - "entities.guid().find(source).or_else(|| { melee .attacker_guid() .find(c.guid) ", - ".and_then(|r| entities.guid().find(r.target_guid)) }); Some(Panicked { guid: ", - "c.guid, at: Point { x: c.x, y: c.y, z: c.z, }, source_at: at.map(|s| Point { ", - "x: s.x, y: s.y, z: s.z, }), frozen: ", - "crate::spell::is_movement_blocked(self.ctx, c.guid), }) }) .collect() } }", - ), - ), - ( - "impl PetSink for CtxWorld<'_> {", - concat!( - "{ fn pets(&self, scope: &TickScope, candidates: &[u64]) -> Vec { let ", - "entities = self.ctx.db.game_world_entity(); let melee = ", - "self.ctx.db.game_melee_attack(); candidates .iter() .filter_map(|guid| ", - "entities.guid().find(guid)) .filter(|p| scope.covers(p.instance_id)) .map(|p| ", - "{ let (command, react, command_target) = pet::pet_command_state(self.ctx, ", - "p.owner_guid); Pet { guid: p.guid, at: Point { x: p.x, y: p.y, z: p.z, }, ", - "map_id: p.map_id, instance_id: p.instance_id, owner: entities .guid() ", - ".find(p.owner_guid) .filter(|o| !o.dead) .map(|o| PetOwner { guid: o.guid, at: ", - "Point { x: o.x, y: o.y, z: o.z, }, map_id: o.map_id, instance_id: ", - "o.instance_id, combat_target: pet::owner_combat_target(self.ctx, &o), }), ", - "dead: p.dead, suppressed: crate::spell::is_self_movement_suppressed(self.ctx, ", - "p.guid), command: PetCommand::of(command, command_target), react: ", - "PetReact::of(react), fighting: melee.attacker_guid().find(p.guid).is_some(), } ", - "}) .collect() } fn pet_may_attack(&self, pet: &Pet, target: u64) -> bool { ", - "pet.owner .is_some_and(|o| crate::creatures::pet::may_attack(self.ctx, o.guid, ", - "pet.guid, target)) } fn nearest_hostile_to(&self, pet: &Pet) -> Option { ", - "pet.owner .and_then(|o| crate::creatures::pet::nearest_hostile_for(self.ctx, ", - "o.guid, pet.guid)) } fn cancel_attack_order(&mut self, owner: u64) { ", - "pet::cancel_attack_order(self.ctx, owner); } fn take_victim(&mut self, guid: ", - "u64, victim: u64) { let melee = self.ctx.db.game_melee_attack(); match ", - "melee.attacker_guid().find(guid) { Some(mut row) => { if row.target_guid != ", - "victim { row.target_guid = victim; melee.attacker_guid().update(row); } } None ", - "=> { melee.insert(MeleeAttack { attacker_guid: guid, target_guid: victim, ", - "last_swing_ms: 0, ranged_spell_id: 0, last_offhand_swing_ms: 0, rout_ends_ms: ", - "0, pursuit_ends_ms: 0, leash_x: 0.0, leash_y: 0.0, }); } } let entities = ", - "self.ctx.db.game_world_entity(); if let Some(mut p) = ", - "entities.guid().find(guid) { if p.target_guid != victim { p.target_guid = ", - "victim; entities.guid().update(p); } } } fn stand_down(&mut self, guid: u64) { ", - "crate::combat::break_own_attacks(self.ctx, guid); } fn dismiss(&mut self, ", - "guid: u64) { pet::despawn_pet(self.ctx, guid); } fn restage(&mut self, guid: ", - "u64, at: Point, map_id: u32, instance_id: u64, now_ms: u32) { let entities = ", - "self.ctx.db.game_world_entity(); let Some(mut p) = entities.guid().find(guid) ", - "else { return; }; let (gx, gy) = spatial::grid_cell(at.x, at.y); p.map_id = ", - "map_id; p.instance_id = instance_id; p.x = at.x; p.y = at.y; p.z = at.z; ", - "p.grid_x = gx; p.grid_y = gy; p.cell = spatial::grid_cell_id(gx, gy); ", - "p.last_move_ms = now_ms; entities.guid().update(p); } }", - ), - ), - ( - "impl CreatureWorld for CtxWorld<'_> {", - concat!( - "{ fn awake_creatures(&self, scope: &TickScope, sense: bool) -> TickSweep { ", - "tick::active_cell_creatures(self.ctx, scope, sense) } fn ", - "run_due_world_maintenance(&mut self) -> Vec<(&'static str, u64)> { vec![ ", - "(\"decay*\", tick::pass_decay(self.ctx) as u64), (\"respawn*\", ", - "tick::pass_respawn(self.ctx) as u64), ( \"go_respawn*\", ", - "tick::pass_gameobject_respawn(self.ctx) as u64, ), ] } fn ", - "evaluate_eventai(&mut self, request: EventAiRequest<'_>) -> u64 { ", - "eventai::evaluate_context(self.ctx, request) } fn ", - "run_package_passes(&mut self) { ", - "crate::hooks::run_package_tick_passes(self.ctx); } }", - ), - ), - ] { - if matches!( - signature, - "impl IdleSink for CtxWorld<'_> {" | "impl PursuitSink for CtxWorld<'_> {" - ) { - continue; - } - let want = want.split_whitespace().collect::>().join(" "); - assert_eq!( - crate::test_scan::shape_of(src, signature), - want, - "`{signature}` is no longer the exact pass-through this harness assumes it is. Every \ - scenario above runs the shared phase bodies with `Scenario` substituted for all of \ - this, so a no-op'd or short-circuited line here leaves the suite green while the \ - world stops moving." - ); - } - let idle = crate::test_scan::shape_of(src, "impl IdleSink for CtxWorld<'_> {"); - for capability in [ - "eventai::movement::intent", - "eventai::movement::explicit_route", - "eventai::movement::follow_target", - "eventai::movement::facing", - "distraction::active", - "distraction::expired_facing", - "distraction::end_expired", - "tick::emit_creature_leg", - ] { - assert!(idle.contains(capability), "IdleSink lost `{capability}`"); - } - let pursuit = crate::test_scan::shape_of(src, "impl PursuitSink for CtxWorld<'_> {"); - for capability in [ - "eventai::ranged_posture", - "eventai::movement::intent", - "eventai::eventai_on_target_not_reachable", - "tick::emit_facing_spline", - ] { - assert!( - pursuit.contains(capability), - "PursuitSink lost `{capability}`" - ); - } -} diff --git a/module/src/creatures/eventai/edges.rs b/module/src/creatures/eventai/edges.rs index 158a0734b..74d617e79 100644 --- a/module/src/creatures/eventai/edges.rs +++ b/module/src/creatures/eventai/edges.rs @@ -548,114 +548,3 @@ pub(crate) fn reset_creature_lifecycle(ctx: &ReducerContext, creature_guid: u64) .creature_guid() .delete(creature_guid); } - -#[cfg(test)] -mod eventai_gate_tripwire { - use crate::test_scan::code_of; - - /// `CreatureAiState.lifecycle_id` is compared in `engine::rule_state_for` and in - /// `presentation::apply_eventai_instruction` to decide whether stored state belongs to the - /// creature standing here now. Both comparisons are vacuous unless the field is seeded from the - /// spawn point's life counter: it once held a hardcoded 1 everywhere, so every comparison read - /// `1 == 1` and passed. That failure is invisible — the code reads correctly and guards nothing. - #[test] - fn creature_state_seeds_its_lifecycle_from_the_spawn_points_life_counter() { - let engine = include_str!("engine.rs"); - let mobility = include_str!("mobility.rs"); - for (source, signature) in [ - ( - engine, - "fn set_eventai_phase(&mut self, creature_guid: u64, phase: u8) {", - ), - ( - engine, - "fn adopt_eventai_revision(\n &mut self,\n creature_guid: u64,\n revision: DefinitionRevision,\n ) -> CreatureState {", - ), - (mobility, "fn default_state("), - ] { - let body = code_of(source, signature); - assert!( - body.contains("lifecycle_id: crate::creatures::current_life_seq("), - "`{signature}` no longer seeds `lifecycle_id` from the spawn point's life counter, so every lifecycle comparison silently passes. Body was:\n{body}" - ); - } - } - - /// A summon guid repeats, because the summon sequence wraps. The summon's life number therefore - /// has to survive every lifetime check: `expire_eventai_summon` re-inserts its row and `auto_inc` - /// hands out a fresh `scheduled_id` each time, so the number must be CARRIED, never re-taken. - /// Re-taking it would refuse a Relay Run on the next check of a perfectly healthy summon. - #[test] - fn a_summon_keeps_one_life_number_across_its_lifetime_checks() { - let mobility = include_str!("mobility.rs"); - let place = code_of(mobility, "pub(super) fn place_summon("); - assert!( - place.contains("expiry.life_seq = sequence;"), - "`place_summon` no longer pins the claim's own id as the summon's life number, so the \ - summon has no identity to Gate on. Body was:\n{place}" - ); - let expire = code_of(mobility, "fn apply_summon_expiry("); - assert!( - expire.contains("life_seq: expiry.life_seq,"), - "the summon expiry body no longer carries the summon's life number across its \ - re-insert, so a healthy summon changes identity on every lifetime check and its Relay \ - Runs are refused. Body was:\n{expire}" - ); - } - - /// The three Gates below decide WHICH creature EventAI touches and WHERE its speech leaves the - /// Module. Each reads durable state through a `ReducerContext`, which this crate has no test - /// harness for, and each fails silently when it is dropped: a tamed beast quietly runs its wild - /// entry's rules and never clears its rule state, and a creature line quietly skips the - /// dead-speaker Gate and the length cap that every other spoken line goes through. - #[test] - fn every_eventai_entry_point_keeps_its_gate() { - for (source, signature, needle, why) in [ - ( - include_str!("engine.rs"), - "fn eventai_fights(&self, scope: &TickScope) -> Vec {", - "super::runs_eventai(&creature)", - "a pet must produce no evaluation context of its own", - ), - ( - include_str!("combat.rs"), - "pub(super) fn definition_for(", - "super::runs_eventai(&creature)", - "the one definition fetch answers `who runs EventAI` for the engine, the edges and the \ - cycle's authored-combat reads alike", - ), - ( - include_str!("edges.rs"), - "pub(crate) fn reset_engagement(", - "runs_eventai(&creature)", - "a Character and a pet carry no engagement to reset", - ), - ( - include_str!("engine.rs"), - "fn eventai_definition(&self, creature_guid: u64) -> EventAiDefinition {", - "super::combat::definition_for(", - "one entry-and-guid definition fetch, not a second copy that drifts from the gated one", - ), - ( - include_str!("engine.rs"), - concat!( - "fn eventai_deliver_line(\n", - " &mut self,\n", - " speaker_guid: u64,\n", - " target_guid: u64,\n", - " chat_type: u8,\n", - " language: u8,\n", - " message: String,\n", - " ) -> bool {", - ), - "crate::chat::apply_send_chat_to(", - "the say/yell chokepoint owns the dead-speaker Gate and the length cap", - ), - ] { - assert!( - code_of(source, signature).contains(needle), - "`{signature}` no longer contains `{needle}` — {why}" - ); - } - } -} diff --git a/module/src/creatures/eventai/fixtures.rs b/module/src/creatures/eventai/fixtures.rs index 48071313b..8f07bcd35 100644 --- a/module/src/creatures/eventai/fixtures.rs +++ b/module/src/creatures/eventai/fixtures.rs @@ -147,7 +147,6 @@ pub fn debug_seed_creature_ai_fixtures(ctx: &ReducerContext) { #[cfg(test)] mod tests { use super::*; - use crate::test_scan::code_of; #[test] fn every_seeded_entry_is_cleared_whoever_wrote_its_definition() { @@ -159,15 +158,6 @@ mod tests { assert!(is_seeded_entry(entry)); } assert!(!is_seeded_entry(1)); - - let body = code_of( - include_str!("fixtures.rs"), - "pub(crate) fn seed_on_aggro_fixtures(ctx: &ReducerContext) {", - ); - assert!( - body.contains("is_seeded_entry(definition.creature_entry)"), - "the seed no longer clears every definition owned by a seeded entry" - ); } #[test] diff --git a/module/src/creatures/eventai/mobility.rs b/module/src/creatures/eventai/mobility.rs index 3d5a0ecd1..b746c7a32 100644 --- a/module/src/creatures/eventai/mobility.rs +++ b/module/src/creatures/eventai/mobility.rs @@ -725,7 +725,6 @@ fn despawn_temporary_summon(ctx: &ReducerContext, creature_guid: u64) { #[cfg(test)] mod tests { use super::*; - use crate::test_scan::code_of; #[test] fn summon_guids_reuse_the_eventai_band_without_corrupting_entry_bits() { @@ -738,15 +737,4 @@ mod tests { ); assert!(summon_guid(123, 0).is_none()); } - - #[test] - fn guardian_removal_includes_spell_created_pets() { - let body = code_of( - include_str!("mobility.rs"), - "pub(crate) fn remove_guardians(", - ); - assert!(body.contains("crate::creatures::pet_of(ctx, summoner_guid)")); - assert!(body.contains("crate::creatures::despawn_pets(ctx, summoner_guid)")); - assert!(!body.contains("game_creature_ai_summon_origin")); - } } diff --git a/module/src/creatures/eventai/mod.rs b/module/src/creatures/eventai/mod.rs index 40ccea86f..70bbf6a30 100644 --- a/module/src/creatures/eventai/mod.rs +++ b/module/src/creatures/eventai/mod.rs @@ -71,174 +71,3 @@ use spacetimedb::ReducerContext; pub(crate) fn evaluate_context(ctx: &ReducerContext, request: EventAiRequest) -> u64 { engine::evaluate(&mut engine::DatabaseWorld::new(ctx), request) } - -#[cfg(test)] -mod architecture_tests { - #[test] - fn source_discriminants_stop_at_the_importer_boundary() { - let tables = include_str!("tables.rs"); - let legacy_start = tables - .find("pub struct CreatureAiEvent {") - .expect("the additive legacy migration table remains present"); - let native_start = tables - .find("/// One normalized native EventAI definition") - .expect("the native definition follows the legacy migration table"); - let legacy_table = &tables[legacy_start..native_start]; - let native_tables = format!("{}{}", &tables[..legacy_start], &tables[native_start..]); - let production = [ - include_str!("model.rs"), - include_str!("engine.rs"), - include_str!("combat.rs"), - include_str!("death.rs"), - include_str!("mobility.rs"), - include_str!("movement.rs"), - include_str!("threat.rs"), - include_str!("quest_credit.rs"), - include_str!("edges.rs"), - include_str!("fixtures.rs"), - include_str!("loader.rs"), - include_str!("presentation.rs"), - include_str!("relay.rs"), - native_tables.as_str(), - ]; - for forbidden in [ - "CMaNGOS", - "cmangos", - "inverse_phase_mask", - "event_type", - "action_type", - "event_param_1", - "action_param_1", - "pub target_policy: u8", - "pub cast_options: u32", - "pub source_flags: u32", - "NATIVE_EVENT_", - "NATIVE_ACTION_", - "NATIVE_TARGET_", - ] { - assert!( - production.iter().all(|source| !source.contains(forbidden)), - "Module production code names importer-only value `{forbidden}`" - ); - } - - for retained_field in [ - "event_type", - "action_type", - "event_param_1", - "action_param_1", - "target_policy", - "cast_options", - "source_flags", - ] { - assert!( - legacy_table.contains(retained_field), - "the frozen legacy migration table lost `{retained_field}`" - ); - } - - let importer = include_str!("../../../../importer/src/eventai.rs"); - for source_value in ["EVENT_TIMER_IN_COMBAT", "ACTION_CAST", "TARGET_HOSTILE"] { - assert!( - importer.contains(source_value), - "the importer no longer owns `{source_value}`" - ); - } - } - - #[test] - fn gateway_does_not_subscribe_module_only_eventai_tables() { - let subscriptions = include_str!("../../../../gateway/src/stdb/connection.rs"); - for table in [ - "game_creature_ai_definition", - "game_creature_ai_event", - "game_creature_ai_state", - "game_creature_ai_rule_state", - "game_creature_ai_broadcast_text", - "game_creature_ai_spell_metadata", - "game_creature_ai_summon", - "game_creature_ai_summon_expiry", - "game_creature_ai_returning_home", - "game_creature_presentation", - "game_creature_ai_movement_intent", - "game_creature_ai_movement_path_waypoint", - "game_creature_ai_relay_definition", - "game_creature_ai_relay_run", - "game_creature_ai_relay_continuation", - "game_creature_ai_relay_arrival", - ] { - assert!( - !subscriptions.contains(&format!("SELECT * FROM {table}")), - "Gateway subscribes Module-only table `{table}`" - ); - } - } - - #[test] - fn quest_credit_reaches_quest_authority_without_writing_quest_tables() { - let capability = include_str!("quest_credit.rs"); - assert!(capability.contains("eventai_credit_quest")); - for table_write in [ - "game_character_quest()", - "game_character_quest_event_credit()", - ".insert(", - ".update(", - ".delete(", - ] { - assert!( - !capability.contains(table_write), - "quest-credit capability directly writes `{table_write}`" - ); - } - } - - #[test] - fn event_producers_stop_at_named_core_chokepoints() { - let swing = include_str!("../../combat/swing.rs"); - assert!(swing.contains("eventai_on_evade(ctx, creature)")); - assert!(swing.contains("eventai_on_spell_hit(")); - - let cycle = include_str!("../cycle/mod.rs"); - let context = include_str!("../cycle/ctx.rs"); - assert!(cycle.contains("w.reached_home(c.guid)")); - assert!(context.contains("eventai_on_reached_home(self.ctx, guid)")); - - let chat = include_str!("../../chat.rs"); - assert!(chat.contains("eventai_on_receive_emote(")); - - let spell = include_str!("../../spell/cast/resolve.rs"); - assert!(spell.contains("eventai_on_spell_hit(")); - - let edges = include_str!("edges.rs"); - for producer in [ - "fn eventai_on_receive_ai_event(", - "fn eventai_on_target_not_reachable(", - ] { - assert!(edges.contains(producer)); - } - } - - #[test] - fn every_first_creature_engagement_uses_the_shared_aggro_edge() { - for source in [ - include_str!("../../combat/swing.rs"), - include_str!("../../spell/effects.rs"), - include_str!("../cycle/ctx.rs"), - include_str!("engine.rs"), - include_str!("mobility.rs"), - ] { - assert!(source.contains("arm_creature_engagement(")); - } - } - - #[test] - fn threat_actions_call_the_authority_without_table_writes() { - let capability = include_str!("threat.rs"); - for direct_table_operation in ["game_threat", ".insert(", ".update(", ".delete("] { - assert!( - !capability.contains(direct_table_operation), - "EventAI threat capability writes through `{direct_table_operation}`" - ); - } - } -} diff --git a/module/src/creatures/eventai/relay.rs b/module/src/creatures/eventai/relay.rs index e5898d27c..f299e6d42 100644 --- a/module/src/creatures/eventai/relay.rs +++ b/module/src/creatures/eventai/relay.rs @@ -2409,40 +2409,10 @@ mod tests { .contains("relay cycle: 1 -> 2 -> 1")); } - /// A creature keeps its guid across death and respawn, so a Relay Run carries the life that - /// started it and refuses to act for any other. Cleanup at the respawn boundary normally removes - /// such a run first; this Gate is what holds when one survives anyway, and it is the half that - /// fails safe rather than acting on the wrong creature. - #[test] - fn a_run_gates_on_the_life_that_started_it() { - let relay = crate::test_scan::code_of(include_str!("relay.rs"), "fn advance_run("); - assert!( - relay.contains( - "run.source_life_seq != crate::creatures::current_life_seq(ctx, run.source_guid)" - ), - "`advance_run` no longer compares the run's life against the source's current life, so \ - a run from a previous life can act on the creature holding that guid now. Body \ - was:\n{relay}" - ); - let start = crate::test_scan::code_of(include_str!("relay.rs"), "fn start_relay("); - assert!( - start.contains("source_life_seq: crate::creatures::current_life_seq(ctx, source_guid)"), - "`start_relay` no longer stamps the source's life onto the run, so the Gate above \ - compares against a default and never refuses. Body was:\n{start}" - ); - } - #[test] fn empty_catalogue_is_a_valid_replacement_graph() { let graph = std::collections::BTreeMap::new(); assert!(validate_definition_graph(&graph).is_ok()); - let loader = crate::test_scan::code_of( - include_str!("relay.rs"), - "fn load_definition_catalogue(ctx: &ReducerContext, packed: &str)", - ); - assert!(!loader.contains("definitions.is_empty()")); - assert!(loader.contains("definition.current = false")); - assert!(loader.contains("reap_unused_definitions(ctx)")); } #[test] @@ -2670,24 +2640,6 @@ mod tests { } } - #[test] - fn teardown_and_failure_paths_keep_the_durable_cleanup_wired() { - let relay = include_str!("relay.rs"); - assert!( - relay.contains("cancel_run_tree(ctx, run.id);\n reap_unused_definitions(ctx);") - ); - assert!(relay.contains( - "cancel_run_tree(ctx, continuation.run_id);\n reap_unused_definitions(ctx);" - )); - assert!(relay.contains("arrivals.by_parent_run().filter(&run_id)")); - assert!(include_str!("../../instance.rs") - .contains("crate::creatures::cancel_relay_runs_for_instance(ctx, instance_id)")); - assert!(include_str!("../tick/lifecycle.rs") - .contains("crate::creatures::cancel_relay_runs_for_source(ctx, guid)")); - assert!(include_str!("edges.rs") - .contains("super::cancel_relay_runs_for_source(ctx, payload.creature_guid)")); - } - #[test] fn dynamic_move_keeps_fixed_distance_and_advances_only_random_ranges() { let mut fixed_state = 12; diff --git a/module/src/creatures/hunter_pet.rs b/module/src/creatures/hunter_pet.rs index 3232033a3..6bbdb3126 100644 --- a/module/src/creatures/hunter_pet.rs +++ b/module/src/creatures/hunter_pet.rs @@ -414,52 +414,10 @@ mod tests { } } - #[test] - fn retame_after_despawn_replaces_the_orphaned_durable_identity() { - // Logout, owner death, Dismiss and pet death all despawn the live pet but keep the durable - // row. Recovery is a retame: the eligibility gates carry no durable-identity check, and - // completion deletes the orphan before the keyed insert so the tame cannot collide. - let fields = - crate::test_scan::code_of(include_str!("hunter_pet.rs"), "struct TameEligibility"); - assert!(!fields.contains("durable")); - let body = crate::test_scan::code_of( - include_str!("hunter_pet.rs"), - "pub(crate) fn tame_creature(", - ); - let delete = body - .find("game_hunter_pet().pet_id().delete(pet_id)") - .expect("retame drops the orphaned durable identity"); - let insert = body - .find("game_hunter_pet().insert(") - .expect("tame inserts the fresh durable identity"); - assert!(delete < insert); - } - #[test] fn missing_and_explicit_zero_live_kind_are_summoned_not_hunter() { assert_eq!(classify_pet_kind(None), PetKind::Summoned); assert_eq!(classify_pet_kind(Some(0)), PetKind::Summoned); assert_eq!(classify_pet_kind(Some(42)), PetKind::Hunter { pet_id: 42 }); } - - #[test] - fn tame_retires_the_entity_but_preserves_and_rearms_its_authored_spawn() { - let body = crate::test_scan::code_of( - include_str!("hunter_pet.rs"), - "pub(crate) fn tame_creature(", - ); - assert!(body.contains("despawn_creature_entity(ctx, wild_guid)")); - assert!(body.contains("game_creature_spawn().guid().update(spawn)")); - assert!(!body.contains("game_creature_spawn().guid().delete(wild_guid)")); - } - - #[test] - fn protocol_projection_uses_the_pet_xp_curve() { - let body = crate::test_scan::code_of( - include_str!("hunter_pet.rs"), - "pub fn publish_hunter_pet_protocol(", - ); - assert!(body.contains("pet_xp_to_next_level(pet.level)")); - assert!(!body.contains("xp::xp_to_next_level(pet.level)")); - } } diff --git a/module/src/creatures/pet_care.rs b/module/src/creatures/pet_care.rs index ef79d22f6..b25e65b58 100644 --- a/module/src/creatures/pet_care.rs +++ b/module/src/creatures/pet_care.rs @@ -418,11 +418,4 @@ mod tests { assert_eq!(food_mask(1), 1); assert_eq!(food_mask(4), 8); } - - #[test] - fn scheduled_care_updates_loyalty_and_the_owner_protocol_projection() { - let body = crate::test_scan::code_of(include_str!("pet_care.rs"), "fn update_care("); - assert!(body.contains("update_pet_loyalty_from_care")); - assert!(body.contains("publish_hunter_pet_protocol")); - } } diff --git a/module/src/creatures/pet_progression.rs b/module/src/creatures/pet_progression.rs index d364660a6..120c2cb23 100644 --- a/module/src/creatures/pet_progression.rs +++ b/module/src/creatures/pet_progression.rs @@ -324,33 +324,4 @@ mod tests { true )); } - - #[test] - fn pet_award_is_gated_on_the_owner_gray_clamp() { - let body = crate::test_scan::code_of( - include_str!("pet_progression.rs"), - "pub(crate) fn award_hunter_pet_kill_progression(", - ); - assert!( - body.contains("xp_for_kill(mob_level, owner.level)"), - "pet XP must reuse the owner's kill-eligibility clamp" - ); - } - - #[test] - fn kill_credit_recipients_are_the_only_pet_award_seam() { - let body = - crate::test_scan::code_of(include_str!("../combat/death.rs"), "fn award_tag_rewards("); - let recipient_loop = body - .find("for recipient in recipients") - .expect("kill rewards still iterate authoritative recipients"); - let pet_award = body - .find("award_hunter_pet_kill_progression(ctx, *recipient") - .expect("pet progression remains inside the shared kill award"); - assert!(pet_award > recipient_loop); - assert_eq!( - body.matches("award_hunter_pet_kill_progression(").count(), - 1 - ); - } } diff --git a/module/src/creatures/spawn.rs b/module/src/creatures/spawn.rs index 55632c8da..5f3e3aeff 100644 --- a/module/src/creatures/spawn.rs +++ b/module/src/creatures/spawn.rs @@ -1020,53 +1020,12 @@ mod tests { use lyracore_shared::constants::unit_flags; #[test] - fn character_materialization_sets_the_player_controlled_flag() { - let materialize = - crate::test_scan::code_of(include_str!("spawn.rs"), "pub fn build_player_entity("); - + fn player_controlled_flag_matches_the_wire_value() { assert_eq!(unit_flags::PLAYER_CONTROLLED, 0x0000_0008); - assert!( - materialize.contains("unit_flags: constants::unit_flags::PLAYER_CONTROLLED,"), - "every world-entry path shares build_player_entity, which must mark its live Character as player-controlled. Body was:\n{materialize}" - ); } #[test] fn combat_and_taxi_flag_transitions_retain_player_controlled() { - let combat = crate::test_scan::code_of( - include_str!("../combat/engage.rs"), - "pub(crate) fn enter_combat(", - ); - let disengage = crate::test_scan::code_of( - include_str!("../combat/engage.rs"), - "pub(crate) fn disengage(", - ); - let taxi = crate::test_scan::code_of(include_str!("../taxi.rs"), "fn activate("); - let taxi_end = - crate::test_scan::code_of(include_str!("../taxi.rs"), "fn cleared_presentation("); - - assert!( - combat.contains("e.unit_flags |= lyracore_shared::constants::unit_flags::IN_COMBAT;"), - "enter_combat must add its bit without replacing Character state. Body was:\n{combat}" - ); - assert!( - disengage - .contains("e.unit_flags &= !lyracore_shared::constants::unit_flags::IN_COMBAT;"), - "disengage must clear only its bit. Body was:\n{disengage}" - ); - assert!( - taxi.contains( - "self.unit_flags |= lyracore_shared::constants::unit_flags::TAXI_FLIGHT;" - ), - "taxi activation must add its bit without replacing Character state. Body was:\n{taxi}" - ); - assert!( - taxi_end.contains( - "state.unit_flags &= !lyracore_shared::constants::unit_flags::TAXI_FLIGHT;" - ), - "taxi completion must clear only its bit. Body was:\n{taxi_end}" - ); - let entered_combat = unit_flags::PLAYER_CONTROLLED | unit_flags::IN_COMBAT; let in_taxi = entered_combat | unit_flags::TAXI_FLIGHT; let left_combat = in_taxi & !unit_flags::IN_COMBAT; diff --git a/module/src/creatures/tick/lifecycle.rs b/module/src/creatures/tick/lifecycle.rs index 00f9c8bb7..45e1c6146 100644 --- a/module/src/creatures/tick/lifecycle.rs +++ b/module/src/creatures/tick/lifecycle.rs @@ -281,246 +281,3 @@ pub(crate) fn pass_gameobject_respawn(ctx: &ReducerContext) -> usize { } visited } - -#[cfg(test)] -mod due_timer_tripwire { - use crate::test_scan::code_of; - - /// The two due-time passes must RANGE-SCAN their index, and must DISARM the timer they fired. - /// - /// Both halves are load-bearing and neither is visible to a behavioural test (these are - /// `ReducerContext` glue — playbook §7), so their shape is pinned: - /// - /// * Drop the range scan and they silently go back to visiting the whole spawn table — the exact - /// cost this work removed (measured: 5,735 rows per pass per sense tick, with nobody online). - /// * Drop the DISARM and the range scan stops narrowing anything, because a fired timer stays in - /// the past and the row is revisited forever. That failure is invisible: behaviour stays - /// correct and only the cost comes back. - /// - /// Moved from `tick.rs`'s combined `due_timer_tripwire` mod to `lifecycle.rs` with - /// the passes it pins (`pass_decay`/`pass_respawn` now live here, not in `tick/mod.rs`). - #[test] - fn the_due_time_passes_range_scan_their_index_and_disarm_after_firing() { - let src = include_str!("lifecycle.rs"); - - let decay = code_of(src, "fn pass_decay(ctx: &ReducerContext) -> usize {"); - assert!( - decay.contains("by_despawn_at()") && decay.contains("filter(..=now_ts)"), - "`pass_decay` no longer range-scans `by_despawn_at` — it is back to a full spawn-table \ - scan every sense tick. Body was:\n{decay}" - ); - assert!( - decay.contains("despawn_at = crate::creatures::timer_never(ctx)"), - "`pass_decay` no longer disarms `despawn_at` after the corpse decays, so the row stays \ - in the range scan forever and the index narrows nothing. Body was:\n{decay}" - ); - - let respawn = code_of(src, "fn pass_respawn(ctx: &ReducerContext) -> usize {"); - assert!( - respawn.contains("by_respawn_at()") && respawn.contains("filter(..=now_ts)"), - "`pass_respawn` no longer range-scans `by_respawn_at`. Body was:\n{respawn}" - ); - // A creature respawns under the guid it already had. Death is the wrong place to end a - // Relay Run, because a death-triggered relay is authored to act on the corpse, so respawn - // is the boundary that must clear anything an earlier life left scheduled. - assert!( - respawn.contains("crate::creatures::cancel_relay_runs_for_new_life(ctx, guid)"), - "`pass_respawn` no longer ends the previous life's Relay Runs, so a run started before \ - the creature died can resume against the new life under the same guid. Body \ - was:\n{respawn}" - ); - // The life counter is what lets a durable row keyed on a reused guid tell the life that - // wrote it from the life standing here now. It must advance BEFORE the entity exists, or a - // reader in this same tick still sees the previous life. - let advance = respawn - .find("s.life_seq = s.life_seq.saturating_add(1)") - .expect("`pass_respawn` no longer advances the spawn point's life counter"); - let materialise = respawn - .find("insert_creature_entity(") - .expect("`pass_respawn` no longer materialises the creature"); - assert!( - advance < materialise, - "`pass_respawn` advances the life counter after materialising the creature, so a \ - reader in this tick sees the previous life. Body was:\n{respawn}" - ); - assert!( - respawn.contains("respawn_at = crate::creatures::timer_never(ctx)"), - "`pass_respawn` no longer disarms `respawn_at` after materialising the creature. Every \ - imported spawn arms `respawn_at = now` deliberately, so without the disarm the ENTIRE \ - imported world sits in the range scan permanently. Body was:\n{respawn}" - ); - } -} - -#[cfg(test)] -mod despawn_checklist_tripwire { - use crate::test_scan::code_of; - - /// **One despawn checklist**. `despawn_creature_entity` is the only place that knows - /// what a dying creature leaves behind; pinning its steps is how a satellite table stops being - /// dropped from the list by a refactor that "only touched one caller". - /// - /// Every step below is invisible to a behavioural test (this crate has no `ReducerContext` - /// harness — playbook §7) and every one of them fails SILENTLY: a leaked spline/motion row hands - /// a fresh subscriber a stale leg for a creature that no longer exists, a leaked taunt lock - /// outlives the creature it locked, and a skipped loot family orphans rows forever (a guid is - /// unique per spawn, so nothing ever collects them). - #[test] - fn the_canonical_despawn_checklist_keeps_every_step() { - let body = code_of( - include_str!("lifecycle.rs"), - "pub(crate) fn despawn_creature_entity(ctx: &ReducerContext, guid: u64) {", - ); - for (needle, why) in [ - ( - "crate::combat::disengage(ctx, guid)", - "the melee engagement + threat (via `threat::clear_for_unit`) + the attackers' \ - stale selections", - ), - ( - "crate::threat::clear_taunt_lock(ctx, guid)", - "the taunt lock (`despawn_tracked` used to skip it entirely)", - ), - ( - "crate::creatures::reset_creature_lifecycle(ctx, guid)", - "the creature's EventAI rule state, phase, and ranged posture", - ), - ( - "crate::combat::clear_lethal_damage_floor(ctx, guid)", - "the EventAI-owned lethal damage floor", - ), - ( - "crate::creatures::cancel_relay_runs_for_source(ctx, guid)", - "source-creature Relay Runs", - ), - ( - "crate::creatures::drop_summon_expiry(ctx, guid)", - "the one-shot lifetime row for a temporary EventAI summon", - ), - ( - "crate::loot::tag::clear(ctx, guid)", - "the live Loot Tag and its retained schema rows", - ), - ( - "ctx.db.game_creature_spline().guid().delete(guid)", - "the LIVE movement leg row (perf 2.3) — the one a client actually animates from", - ), - ( - "ctx.db.game_entity_motion().guid().delete(guid)", - "the per-mover motion row (perf 2.1)", - ), - ( - "crate::motion::drop_pending(ctx, guid)", - "the STAGED motion payload (#461) — left behind, the next `publish_motion` firing \ - would re-create the motion row this fn just deleted", - ), - ( - "crate::loot::reap_corpse_loot_family(ctx, guid)", - "the whole loot family — item rows, eligibility snapshot, rolls, votes", - ), - ( - "ctx.db.game_world_entity().guid().delete(guid)", - "the entity itself, whose `on_delete` relay is what fires SMSG_DESTROY_OBJECT", - ), - ] { - assert!( - body.contains(needle), - "`despawn_creature_entity` no longer does `{needle}` — it stopped cleaning up {why}. \ - Every teardown path routes through this fn (issue #359), so a step dropped here is \ - dropped for the instance reap, the encounter wipe AND corpse decay at once. Body \ - was:\n{body}" - ); - } - } - - /// **The three teardown paths route through the checklist and re-implement none of it.** - /// - /// This is the actual regression: `encounter::despawn_tracked` carried a private copy - /// that deleted `game_corpse_loot` by corpse guid with NO `!withheld` filter, so a wipe mid - /// NEED/GREED roll deleted the item out from under a roll that — on a sharded realm — is - /// authoritative on realm-core and invisible locally, and `settle_loot_roll`'s grant then found - /// nothing and silently no-opped. A raw `game_corpse_loot()` handle in any of these bodies is - /// that bug growing back. - #[test] - fn every_creature_teardown_path_routes_through_the_checklist() { - let tracked = code_of( - include_str!("../../encounter.rs"), - "fn despawn_tracked(ctx: &ReducerContext, tracked: &[EncounterSpawn]) {", - ); - assert!( - tracked.contains("crate::creatures::despawn_creature_entity(ctx, t.guid)"), - "`encounter::despawn_tracked` no longer routes through the canonical checklist. Body \ - was:\n{tracked}" - ); - assert!( - !tracked.contains("game_corpse_loot()"), - "`encounter::despawn_tracked` touches `game_corpse_loot` directly again — that is the \ - issue-#359 bug: an unfiltered delete there eats a WITHHELD row still owned by a live \ - cross-shard loot roll (the #50 invariant). Route it through \ - `loot::reap_corpse_loot_family`. Body was:\n{tracked}" - ); - - let teardown = code_of( - include_str!("../../instance.rs"), - "pub(crate) fn teardown_instance_inner(ctx: &ReducerContext, instance_id: u64, \ - delete_row: bool) {", - ); - assert!( - teardown.contains("crate::creatures::despawn_creature_entity(ctx, *guid)"), - "`teardown_instance_inner`'s population sweep no longer routes through the canonical \ - checklist. Body was:\n{teardown}" - ); - assert!( - !teardown.contains("game_corpse_loot()") && !teardown.contains("game_threat()"), - "`teardown_instance_inner` hand-rolls loot-family or threat cleanup again — `disengage` \ - (inside the checklist) already clears threat both ways, and the loot family belongs to \ - `loot::reap_corpse_loot_family`. Body was:\n{teardown}" - ); - - let decay = code_of( - include_str!("lifecycle.rs"), - "fn pass_decay(ctx: &ReducerContext) -> usize {", - ); - assert!( - decay.contains("crate::creatures::despawn_creature_entity(ctx, guid)"), - "`pass_decay` no longer routes corpse decay through the canonical checklist. Body \ - was:\n{decay}" - ); - assert!( - decay.contains(".any(|l| l.withheld)"), - "`pass_decay` lost its #50 withheld gate — the plane-agnostic \"a roll still owns this \ - corpse\" check that keeps a corpse one more tick instead of decaying a live cross-shard \ - roll. Body was:\n{decay}" - ); - } - - /// The invariant lives in ONE function now (`loot::reap_corpse_loot_family`, moved there from - /// `instance.rs` to sit next to the tables it cleans). Losing the `!withheld` - /// filter silently breaks the cross-shard NEED/GREED grant on every teardown path at once. - #[test] - fn the_canonical_loot_family_reap_never_deletes_a_withheld_row() { - let body = code_of( - include_str!("../../loot/mod.rs"), - "pub(crate) fn reap_corpse_loot_family(ctx: &ReducerContext, corpse_guid: u64) {", - ); - assert!( - body.contains(".filter(|l| !l.withheld)"), - "`reap_corpse_loot_family` no longer skips WITHHELD rows (issue #50): a row locked by a \ - loot roll that is authoritative on realm-core is invisible to this database, so \ - deleting it makes `settle_loot_roll`'s grant find nothing and silently no-op. Body \ - was:\n{body}" - ); - for table in [ - "game_corpse_loot_eligible()", - "game_loot_roll()", - "game_loot_roll_vote()", - ] { - assert!( - body.contains(table), - "`reap_corpse_loot_family` no longer reaps `{table}` — the loot family is what every \ - despawn path delegates here, so a missed table orphans rows on a guid nothing will \ - ever revisit. Body was:\n{body}" - ); - } - } -} diff --git a/module/src/creatures/tick/mod.rs b/module/src/creatures/tick/mod.rs index 281f4d0fd..77e5f4902 100644 --- a/module/src/creatures/tick/mod.rs +++ b/module/src/creatures/tick/mod.rs @@ -1004,49 +1004,6 @@ mod relay_tripwire { assert!(!super::active_object_enters_scope(false, false, true)); assert!(!super::active_object_enters_scope(false, true, false)); } - - /// **The relay has ONE writer.** perf 2.3 moved creature legs onto the AOI-scoped - /// `game_creature_spline` row and, in the same commit, removed the gateway's global subscription - /// to `game_creature_move_event` on the stated grounds that "nothing writes the table any more". - /// FIVE writers were still there — bot legs, the CC freeze, the chase stop, - /// `encounter::move_creature_to` and Charge — and every one of them went silently undelivered: - /// the server moved, no client ever saw it. Bots stood frozen in front of players for days. - /// - /// A relay whose subscriber is gone fails EXACTLY this quietly, so the invariant is pinned by - /// scan: no module or package source may INSERT into that table. (Reads/reaps are fine — the - /// table still exists and `gc.rs` sweeps whatever is left.) - /// - /// This used to be a HAND-PICKED file list (`tick.rs` + `encounter.rs` + `spell/cast.rs` + - /// a Package file), and the list omitted `creatures/pet.rs` — the Follow leg it writes leaked - /// undelivered rows into this table every sense tick, unbounded, on a live shard, and the scan - /// never saw it. Scan the WHOLE compiled tree instead — `character_owned_tripwire::scanned_files` - /// already walks `module/src` plus every installed `packages/*/src`, so a new file (or a moved - /// one) is covered for free instead of needing a second edit to add it to a list. The old - /// hand-list also self-excluded ITS OWN body from the scan via a `.split_once("mod tests")` that - /// only worked for `tick.rs` because that call's own text happens to contain "mod tests" earlier - /// in the file than the needle — a coincidence, not a real test-module boundary. Build the needle - /// at RUN time instead so it is never spelled out contiguously in source anywhere, which is - /// self-exclusion that can't rot. - /// - /// This test used to live in `tick.rs`'s `due_timer_tripwire` mod alongside the - /// decay/respawn and aggro tripwires below. Split with the file it pins: `emit_move_spline` - /// (the ONE writer this test protects) stays in `tick/mod.rs`, so the test stays here too — the - /// other two moved to `lifecycle.rs`/`sense.rs` with the passes they actually test. - #[test] - fn nothing_writes_the_unsubscribed_move_event_table() { - let needle = format!("{}{}", "game_creature_move_event()", ".insert"); - for file in crate::tripwires::character_owned_tripwire::scanned_files() { - let src = std::fs::read_to_string(&file) - .unwrap_or_else(|e| panic!("cannot read {}: {e}", file.display())); - assert!( - !src.contains(&needle), - "{} inserts into `game_creature_move_event`, which NO subscriber reads since perf \ - 2.3 — the movement it emits will never reach a client. Emit the leg through \ - `creatures::tick::emit_move_spline` (or `emit_creature_leg`) instead.", - file.display() - ); - } - } } #[cfg(test)] diff --git a/module/src/duel.rs b/module/src/duel.rs index a40737906..ea979abbb 100644 --- a/module/src/duel.rs +++ b/module/src/duel.rs @@ -812,24 +812,4 @@ mod tests { ); assert_eq!(duel_flag_guid(7, crate::realm_core::GUID_RANGE_SIZE), None); } - - #[test] - fn completion_path_deletes_before_emitting_and_absence_is_a_noop() { - let source = include_str!("duel.rs"); - let start = source - .find("pub(crate) fn complete_duel(") - .expect("completion chokepoint exists"); - let end = source[start..] - .find("pub(crate) fn interrupt_duel_for(") - .map(|offset| start + offset) - .expect("completion chokepoint has a bounded body"); - let body = &source[start..end]; - assert!(body.contains("let Some(duel) = duels.id().find(duel_id) else")); - assert!(body.contains("return false;")); - assert!(body.contains("crate::combat::stop_duel_combat")); - assert!( - body.find("duels.id().delete(duel_id)").unwrap() < body.find("push_event(").unwrap(), - "state must disappear before any completion edge is emitted" - ); - } } diff --git a/module/src/gc.rs b/module/src/gc.rs index d6efdf6d0..18e34892f 100644 --- a/module/src/gc.rs +++ b/module/src/gc.rs @@ -212,27 +212,3 @@ pub fn reap_movement_events(ctx: &ReducerContext, _schedule: EventReaperSchedule // not this reaper). See `corpse::sweep_corpse_decay`'s doc for the timers and the relay shape. crate::corpse::sweep_corpse_decay(ctx); } - -#[cfg(test)] -mod tests { - use crate::test_scan::code_of; - - #[test] - fn movement_violation_rows_are_ttl_reaped() { - let body = code_of(include_str!("gc.rs"), "pub fn reap_movement_events("); - let reaper_call = ["reap!(", "game_movement_violation", ");"].concat(); - assert!( - body.contains(&reaper_call), - "the shared event reaper must delete expired movement-violation diagnostics" - ); - } - - #[test] - fn lapsed_cc_diminishing_return_rows_are_reaped() { - let body = code_of(include_str!("gc.rs"), "pub fn reap_movement_events("); - assert!( - body.contains("stacking::sweep_dr_state(ctx)"), - "the shared event reaper must drop lapsed crowd-control diminishing-return rows" - ); - } -} diff --git a/module/src/group.rs b/module/src/group.rs index c5683fca6..6310b21db 100644 --- a/module/src/group.rs +++ b/module/src/group.rs @@ -3578,141 +3578,4 @@ mod tests { } // event_recipient_identity's pinned test moved to helpers.rs with the function itself. - - // A reducer body needs a live `ReducerContext`, so the authorization and dispatch decisions - // below use narrow Architecture Tests. - - /// The `//`-stripped body of `signature`'s function — assert on CODE, never on the prose beside - /// it. Shared with every other file's copy of this scan as [`crate::test_scan::code_of`] - /// (this used to be six near-identical, drifted-apart copies). - use crate::test_scan::code_of; - - /// **The operator gate is the entire authorization of Gateway-driven party work.** - /// - /// The claim can consume any invite intent, `realm_group_op` can act as any Character, and - /// `sync_group_mirror` can replace a whole party roster. Only the Operator may supply those - /// arguments. - #[test] - fn gateway_party_reducers_are_operator_gated() { - for f in [ - "pub fn claim_bot_invite_intent(", - "pub fn realm_group_op(", - "pub fn sync_group_mirror(", - ] { - let body = code_of(include_str!("group.rs"), f); - // The FIRST STATEMENT, not merely present (review). A bare `contains` was - // satisfied by a gate that never runs: wrapping the line in `if false { … }` — the - // tripwire defeat this batch has already documented twice — left all 521 module tests - // green with `realm_group_op` completely ungated, and so would `let _ = …` or an early - // return above it. Anchoring to the opening brace makes all three visible. - let normalized: String = body.split_whitespace().collect::>().join(" "); - assert!( - normalized.starts_with("{ crate::helpers::require_operator(ctx)?;"), - "`{f}` no longer OPENS with the operator gate. Its arguments authorize party work, \ - so a gate that is present but neutralized (wrapped in `if false`, `let _ =`, or \ - preceded by an early return) is no gate. \ - Body was:\n{body}" - ); - } - } - - /// The primary-key delete is the arbitration. A read followed by a second Durable Request - /// would let two Gateways win against the same row. - #[test] - fn the_bot_invite_claim_is_one_atomic_delete() { - let body = code_of(include_str!("group.rs"), "pub fn claim_bot_invite_intent("); - let normalized: String = body.split_whitespace().collect::>().join(" "); - assert!( - normalized.contains("game_bot_invite_intent().id().delete(intent_id)"), - "the claim no longer deletes the intent by primary key in its reducer transaction. \ - Body was:\n{body}" - ); - } - - /// The op byte is a wire value the gateway sends and this reducer dispatches on, and - /// `lyracore_shared::group::realm_op` pins only the NUMBERS. Nothing pinned what each number DOES - /// here, so swapping two arms was a silent mis-dispatch — "an ACCEPT arriving as a LEAVE", the - /// exact failure that shared constant's doc claims to prevent — with every suite green. - #[test] - fn every_realm_op_byte_dispatches_to_its_own_core() { - let body = code_of(include_str!("group.rs"), "pub fn realm_group_op("); - for (op, core) in [ - ( - "realm_op::INVITE =>", - "invite_core_on(ctx, Plane::RealmCore, actor_guid, target_guid)", - ), - ( - "realm_op::ACCEPT =>", - "accept_invite_on(ctx, Plane::RealmCore, actor_guid, arg_a)", - ), - ("realm_op::DECLINE =>", "decline_invite_on(ctx, actor_guid)"), - ( - "realm_op::LEAVE =>", - "leave_group_on(ctx, actor_guid, arg_a)", - ), - ( - "realm_op::UNINVITE =>", - "uninvite_on(ctx, actor_guid, target_guid)", - ), - ( - "realm_op::LOOT_METHOD =>", - "set_loot_method_on(ctx, actor_guid, arg_a, target_guid, arg_b)", - ), - ( - "realm_op::RAID_CONVERT =>", - "raid_convert_on(ctx, actor_guid)", - ), - ( - "realm_op::SET_LEADER =>", - "set_leader_on(ctx, actor_guid, target_guid)", - ), - ( - "realm_op::SET_ASSISTANT =>", - "set_assistant_on(ctx, actor_guid, target_guid, arg_a != 0)", - ), - ( - "realm_op::CHANGE_SUBGROUP =>", - "change_subgroup_on(ctx, actor_guid, target_guid, arg_a)", - ), - ( - "realm_op::SWAP_SUBGROUP =>", - "swap_subgroup_on(ctx, actor_guid, target_guid, arg_c)", - ), - ( - "realm_op::READY_CHECK_START =>", - "ready_check_start_on(ctx, actor_guid)", - ), - ( - "realm_op::READY_CHECK_ANSWER =>", - "ready_check_answer_on(ctx, actor_guid, arg_a)", - ), - ( - "realm_op::TARGET_ICON =>", - "target_icon_on(ctx, actor_guid, arg_a, target_guid)", - ), - ( - "realm_op::MINIMAP_PING =>", - "minimap_ping_on(ctx, actor_guid, target_guid, arg_c)", - ), - ( - "realm_op::RANDOM_ROLL =>", - "random_roll_on(ctx, actor_guid, target_guid, arg_c)", - ), - ] { - let arm = body.split(op).nth(1).unwrap_or_else(|| { - panic!("`realm_group_op` no longer dispatches `{op}`. Body was:\n{body}") - }); - // Whitespace-normalised, and a `{` block wrapper stripped: an arm split across lines or - // wrapped in braces is the same dispatch, and neither should be able to hide a swap. - let arm: String = arm.split_whitespace().collect::>().join(" "); - let arm = arm.strip_prefix("{ ").unwrap_or(&arm); - assert!( - arm.starts_with(core), - "`{op}` no longer runs `{core}`. The op byte is a WIRE value the gateway sends and \ - this match dispatches on; a swapped arm runs the wrong op for every player, \ - silently. Arm was:\n{}", - &arm[..arm.len().min(120)] - ); - } - } } diff --git a/module/src/guild/fee.rs b/module/src/guild/fee.rs index c5bf851cc..021e79fc7 100644 --- a/module/src/guild/fee.rs +++ b/module/src/guild/fee.rs @@ -1467,37 +1467,4 @@ mod tests { assert_eq!(hold.copper, 100_000); assert_eq!(HeldFee::from_row(hold.clone().into_row(0)), Some(hold)); } - - /// The actor guid is an argument, so the operator gate is the whole authorization. - #[test] - fn fee_reducers_open_with_the_operator_gate() { - for signature in [ - "pub fn gw_guild_fee_hold(", - "pub fn realm_guild_fee_decide(", - "pub fn gw_guild_fee_finish(", - ] { - let body = crate::test_scan::code_of(include_str!("fee.rs"), signature); - let normalized = body.split_whitespace().collect::>().join(" "); - assert!( - normalized.starts_with("{ crate::helpers::require_operator(ctx)?;"), - "`{signature}` no longer opens with the operator gate. Body was:\n{body}" - ); - } - } - - #[test] - fn character_delete_refuses_a_fee_hold_before_the_cascade() { - let body = - crate::test_scan::code_of(include_str!("../auth.rs"), "pub fn delete_character("); - let fee_gate = body - .find("crate::guild::fee::character_has_fee_hold") - .expect("character deletion must check for a Fee Hold"); - let cascade = body - .find("crate::world::cascade_delete_character") - .expect("character deletion still needs its normal cascade"); - assert!( - fee_gate < cascade, - "a Fee Hold must be fenced before deletion" - ); - } } diff --git a/module/src/guild/mod.rs b/module/src/guild/mod.rs index abd2f8d1f..3bab3ca47 100644 --- a/module/src/guild/mod.rs +++ b/module/src/guild/mod.rs @@ -460,19 +460,6 @@ pub fn push_event( #[cfg(test)] mod tests { use super::*; - use crate::test_scan::code_of; - - /// The actor guid is an argument, so the operator gate is the whole authorization. A gate - /// that is present but neutralized (`if false`, `let _ =`, an early return) is no gate. - #[test] - fn the_realm_guild_op_reducer_opens_with_the_operator_gate() { - let body = code_of(include_str!("mod.rs"), "pub fn realm_guild_op("); - let normalized = body.split_whitespace().collect::>().join(" "); - assert!( - normalized.starts_with("{ crate::helpers::require_operator(ctx)?;"), - "`realm_guild_op` no longer opens with the operator gate. Body was:\n{body}" - ); - } /// BSATN encodes a `GuildOp` variant as its ordinal position, so appending a variant in the /// wrong place silently renames every variant after it on the wire. This pins each variant's diff --git a/module/src/guild/petition.rs b/module/src/guild/petition.rs index df861c4cf..a7e7d7dad 100644 --- a/module/src/guild/petition.rs +++ b/module/src/guild/petition.rs @@ -700,18 +700,4 @@ mod tests { .collect(); assert_eq!(joining, [5_090_511, 5_090_513]); } - - /// The actor guid is an argument, so the operator gate is the whole authorization. - #[test] - fn destroying_a_charter_opens_with_the_operator_gate() { - let body = crate::test_scan::code_of( - include_str!("petition.rs"), - "pub fn gw_destroy_guild_charter(", - ); - let normalized = body.split_whitespace().collect::>().join(" "); - assert!( - normalized.starts_with("{ crate::helpers::require_operator(ctx)?;"), - "`gw_destroy_guild_charter` no longer opens with the operator gate. Body was:\n{body}" - ); - } } diff --git a/module/src/gw.rs b/module/src/gw.rs index 1c12efc90..c07e9dbd8 100644 --- a/module/src/gw.rs +++ b/module/src/gw.rs @@ -1704,46 +1704,5 @@ mod tests { |movement| apply_model(&mut states, movement), ); assert_eq!(states[&12].relay, Some((HEARTBEAT, vec![6]))); - - let body = crate::test_scan::code_of(include_str!("gw.rs"), "pub fn gw_movement_batch("); - let apply_at = body - .find("apply_movement_batch(") - .expect("batch application"); - let publish_at = body.find("publish_staged(ctx)").expect("inline publish"); - assert!( - apply_at < publish_at, - "the reducer must publish after applying the whole batch" - ); - } - /// Every reducer in THIS file must open with its gate — `require_operator` for a gateway - /// verb, the scheduler-only sender fence for a scheduled reducer — before it resolves or - /// touches anything else. The trust model of the whole surface, pinned structurally: split - /// the file on the reducer attribute, and each following fn body's FIRST statement must be - /// one of the two gates. A verb added without one fails here, not in review. - #[test] - fn every_gateway_verb_gates_on_require_operator_first() { - let src = include_str!("gw.rs"); - // Built at runtime so this test's own source can never match the needle. - let needle = format!("#[{}]", "reducer"); - let mut chunks = src.split(needle.as_str()); - chunks.next(); // preamble - let mut seen = 0; - for chunk in chunks { - let body = chunk - .split_once('{') - .map(|(_, b)| b) - .unwrap_or("") - .trim_start(); - assert!( - body.starts_with("require_operator(ctx)?;") - || body.starts_with("require_operator(ctx).map_err(loot_operator_error)?;") - || body.starts_with("if ctx.sender() != ctx.database_identity()"), - "a reducer here must open with the direct Operator gate (gateway verb) or the \ - scheduler-only sender fence (scheduled reducer) — got:\n{}", - &body[..body.len().min(120)] - ); - seen += 1; - } - assert!(seen >= 1, "the scan found no reducers — needle drifted"); } } diff --git a/module/src/instance.rs b/module/src/instance.rs index 236e94d4d..743cde040 100644 --- a/module/src/instance.rs +++ b/module/src/instance.rs @@ -1455,164 +1455,6 @@ mod tests { assert_eq!(route_instance(None, None), InstanceRoute::Create); } - /// Defect 3, the half a pure function cannot express: the FIRST party member through the - /// portal may be the one holding the solo binding, and `by_party` cannot see a `party_id = 0` - /// instance — so the members behind them would mint a second dungeon unless the instance they - /// walk into is re-stamped as the party's. Source-scanned (the `transfer.rs` tripwire pattern): - /// the module crate has no `ReducerContext` harness, and deleting this call site left every - /// other test in this file green. - #[test] - fn the_own_binding_arm_adopts_a_solo_instance_into_the_callers_party() { - let body = code_of( - include_str!("instance.rs"), - "pub(crate) fn resolve_or_create_instance(", - ); - assert!( - body.contains("adopt_instance_for_party"), - "resolve_or_create_instance's Own arm no longer adopts a solo instance into the \ - caller's party. Without it, a party formed AFTER one member's solo entry splits: that \ - member re-enters their own instance and everyone else creates a second one (#39)." - ); - // ...and adoption must never STEAL an instance another party already owns. - let adopt = code_of(include_str!("instance.rs"), "fn adopt_instance_for_party("); - let ownerless = code_of(include_str!("instance.rs"), "fn instance_is_ownerless("); - assert!( - adopt.contains("if instance_is_ownerless(ctx, inst.party_id) {") - && ownerless.contains( - "party_id == 0 || ctx.db.game_group().group_id().find(party_id).is_none()" - ), - "adopt_instance_for_party no longer restricts itself to ownerless instances (solo, or \ - whose Group disbanded). It would re-stamp a living party's dungeon as this caller's" - ); - // Adversarial review: a call site plus a guard TEXT is not adoption. Inverting the caller's - // own no-party guard (`party_id == 0` → `!= 0`) leaves every string above present and every - // test in this crate green while adoption never runs once — the exact dead-code shape that - // has defeated this repo's source scans before. Pin the guard's SENSE, not just its words. - assert!( - adopt.contains("if party_id == 0 {"), - "adopt_instance_for_party's no-party guard changed sense — it must return early for a \ - caller with NO party (0) and adopt for everyone else; inverted, adoption is dead code" - ); - // The party lookup this all hangs off must stay MAP-SCOPED. A party-first order that - // resolves the group's instance on ANY map sends a member who walks into the Stockades - // portal into the party's Deadmines instance — and binds them to it under `target_map`. - assert!( - body.contains("i.map_id == target_map"), - "the party's live-instance lookup is no longer filtered to the map being entered — \ - party-first would resolve a member into the group's instance of a DIFFERENT dungeon" - ); - // Both resolve arms that hand a character an instance they were not already bound to must - // BIND them (Party and Create). A Party arm that skips it re-mints an instance on that - // member's next entry — the split this ticket exists to close, one entry later. - assert_eq!( - body.matches("bind_character(ctx, character_guid, id, target_map)") - .count(), - 2, - "the Party and Create arms must BOTH bind the character to the instance they resolved" - ); - } - - /// Defect 1: entering a portal for a map another database owns must never spawn the - /// dungeon HERE. The spawn loop is a `ReducerContext` walk with no unit harness, so the gate in - /// front of it is source-scanned — the mutation that matters (deleting the early return) leaves - /// every behavioural test in the workspace green. - #[test] - fn instance_population_is_gated_on_this_database_hosting_instances() { - let body = code_of( - include_str!("instance.rs"), - "pub(crate) fn create_instance_with_id(", - ); - let gate = body.find("hosts_instance_populations(ctx)").expect( - "create_instance_with_id no longer consults game_config.hosts_instances — the \ - open-world shard is spawning dungeon populations it will never tick (#39)", - ); - let spawn = body - .find("build_creature_entity") - .expect("create_instance_with_id no longer spawns a population at all"); - assert!( - gate < spawn, - "the hosting gate must come BEFORE the population spawn, or the world shard pays for \ - the dungeon anyway" - ); - assert!( - body[gate..spawn].contains("return Ok(instance_id)"), - "the hosting gate no longer RETURNS — it must file the lease row and stop, not fall \ - through into the spawn loop" - ); - // Adversarial review: `find` + an ordering + a `return` all pass for a gate that can never - // FIRE — `if !hosts_instance_populations(ctx) && ` keeps every assertion - // above green while the world shard spawns every dungeon. Pin the condition itself. - assert!( - body.contains("if !hosts_instance_populations(ctx) {"), - "the hosting gate's condition grew an extra term — the gate must be exactly \ - `if !hosts_instance_populations(ctx)`, or it can be made unreachable while every \ - source scan above still passes" - ); - // The reader itself must default to hosting, so a database with no config row (or one that - // predates the column) behaves exactly as it did. - let reader = code_of( - include_str!("instance.rs"), - "pub(crate) fn hosts_instance_populations(", - ); - assert!( - reader.contains("unwrap_or(true)"), - "hosts_instance_populations must default to TRUE — a missing game_config row would \ - otherwise turn every single-database realm's dungeons into empty rooms" - ); - // ...and it must read the COLUMN. A mutation that kept the call, the default and the gate - // but answered a constant left every test in this crate green. - assert!( - reader.contains("c.hosts_instances"), - "hosts_instance_populations no longer reads game_config.hosts_instances — the gate is \ - wired to a constant and the world shard spawns dungeons regardless of the operator's \ - configuration" - ); - // ...from the SINGLETON row. `game_config` is keyed on `id = 0` (seed::init, and every - // operator SQL line in the runbook); reading any other id finds nothing, falls into - // `unwrap_or(true)`, and hosts instances no matter what the operator configured — with - // every assertion above still green. Adversarial review: this mutation survived. - assert!( - reader.contains("find(0)"), - "hosts_instance_populations reads a game_config row other than the id=0 singleton — it \ - would find nothing and silently default to hosting on every shard" - ); - } - - /// The single-database promise: `hosts_instances` must default to ON in BOTH places a value can - /// come from — the auto-migration default for a live database that predates the column, and the - /// `seed::init` insert for a fresh one. Either flipped to `false` silently turns every - /// unconfigured realm's dungeons into empty rooms, and no behavioural test in this workspace - /// would notice. - #[test] - fn hosting_defaults_to_on_in_both_the_column_default_and_the_seed() { - let cfg = include_str!("config.rs"); - let decl = cfg - .find("pub hosts_instances: bool") - .expect("game_config.hosts_instances is gone — #39's routing gate has no input"); - assert!( - cfg[..decl].trim_end().ends_with("#[default(true)]"), - "hosts_instances must carry #[default(true)]: it is END-APPENDED to a LIVE table, and a \ - `false` default would switch dungeon spawning off on every existing database the moment \ - it auto-migrates" - ); - // `init` is a 4-line dispatcher over four banner-stratum fns now (see seed.rs's - // header) — `game_config` is seeded in stratum 1, `seed_production_core`. - let seed = code_of( - include_str!("seed.rs"), - "fn seed_production_core(ctx: &ReducerContext) {", - ); - assert!( - seed.contains("hosts_instances: true"), - "seed::seed_production_core must seed hosts_instances = true — a fresh single-database \ - realm hosts its own dungeons, exactly as it did before #39" - ); - } - - /// Isolate one fn body and strip its `//` prose, so a tripwire asserts on CODE and not on the - /// comment that explains it. Shared as [`crate::test_scan::code_of`] (this used to - /// be six near-identical, drifted-apart copies). - use crate::test_scan::code_of; - #[test] fn party_size_cap_admits_up_to_five_and_refuses_six() { for n in 1..=5 { @@ -1744,74 +1586,6 @@ mod tests { } } - /// **AC#1**, the half that is a cost statement rather than a behaviour: the reaper on a - /// database with no instances must not pay for the O(entities) occupancy pass. Source-scanned - /// like the gate above — the reducer body has no unit harness, and moving the early return - /// below the scan leaves every behavioural test in the workspace green while the open-world - /// writer starts paying, once a minute, for a table it has no rows in. - #[test] - fn the_reaper_costs_a_database_with_no_instances_nothing() { - let body = code_of(include_str!("instance.rs"), "pub fn reap_instances("); - let rows = body - .find("game_instance().iter().collect()") - .expect("the reaper reads its rows"); - let bail = body.find("if instances.is_empty()").expect( - "reap_instances no longer short-circuits on an empty game_instance table — a database \ - that hosts no instances would scan every world entity once a minute for nothing (#21)", - ); - let scan = body - .find("occupied_instances(ctx)") - .expect("the reaper classifies occupancy"); - assert!( - rows < bail && bail < scan, - "the empty short-circuit must precede the entity scan" - ); - assert!( - body[bail..scan].contains("return;"), - "the empty short-circuit no longer RETURNS — it must stop, not fall through" - ); - // The per-database timer has to be READ from this database's own config, or a lease-only - // shard silently reaps its stubs on the 30-minute run timer (see the AC#4 test above). - assert!( - body.contains("empty_reap_micros(hosts_instance_populations(ctx))"), - "reap_instances no longer derives its empty timer from THIS database's \ - hosts_instances flag — leases would be reaped mid-run on the world shard (#21)" - ); - } - - /// **AC#1**, per-instance schedules. Nothing that ticks an instance may be armed on a - /// database that does not host its population: the lease path must file the row and stop. - #[test] - fn a_lease_arms_no_per_instance_schedule_and_spawns_no_population() { - let body = code_of( - include_str!("instance.rs"), - "pub(crate) fn create_instance_with_id(", - ); - let gate = body - .find("hosts_instance_populations(ctx)") - .expect("the #39 gate"); - let stop = body - .find("return Ok(instance_id)") - .expect("the lease path returns"); - assert!(gate < stop); - // Everything that costs a writer per instance lives AFTER the lease return. (Today - // `create_instance` arms no tick row at all — perf catalog 1.3 — so this is the pin that - // notices if one ever comes back on the wrong side of the gate.) - for costly in [ - "build_creature_entity", - "gos.insert(", - "game_creature_move_schedule", - ] { - if let Some(at) = body.find(costly) { - assert!( - at > stop, - "`{costly}` runs BEFORE the lease return — a database that does not host \ - instance populations would pay for this instance anyway (#21 AC#1)" - ); - } - } - } - #[test] fn stranding_fallback_prefers_entrance_then_in_place_then_home() { // A known dungeon map: its entrance const (the map-36 arm). @@ -1853,38 +1627,6 @@ mod tests { assert!(!instance_removal_due(36, 7, 42, None, 1), "a GM"); } - /// Login is where a Character re-enters a former Group's instance through its own binding, - /// and where a relog inside the countdown keeps it. The call must follow the entity insert and - /// the Character update, or it reads the location the login is about to replace. - #[test] - fn login_reconciles_the_instance_removal_after_the_character_is_in_world() { - let login = code_of( - include_str!("world.rs"), - "pub(crate) fn apply_player_login(", - ); - let reconcile = login - .find("crate::instance::reconcile_instance_removal(ctx, character_guid)") - .expect("apply_player_login no longer reconciles the Instance Removal"); - let insert = login.find("entities.insert(entity)").unwrap(); - let update = login.find("chars.guid().update(character)").unwrap(); - assert!(insert < reconcile && update < reconcile); - } - - /// A hearthstone, GM teleport or summon out of the instance ends the countdown in the same - /// transaction, so its hide reaches the client before the teleport does. - #[test] - fn a_teleport_that_changes_partition_reconciles_the_instance_removal() { - let teleport: String = code_of(include_str!("world.rs"), "pub(crate) fn teleport_player(") - .split_whitespace() - .collect(); - assert!( - teleport.contains("letchanges_partition=e.map_id!=map_id||e.instance_id!=instance_id;") - ); - assert!(teleport.contains( - "ifchanges_partition{crate::instance::reconcile_instance_removal(ctx,player_guid);}" - )); - } - #[test] fn reset_eligibility_requires_unoccupied_and_leadership_for_party_instances() { // Solo instance (party 0): any bound caller, but never while occupied. diff --git a/module/src/items/economy.rs b/module/src/items/economy.rs index ba82bc5ae..3df687198 100644 --- a/module/src/items/economy.rs +++ b/module/src/items/economy.rs @@ -575,7 +575,6 @@ mod tests { buy_bank_slot_result, buyback_newest_first, buyback_ring_full, flagged_npc_in_reach, BuyBankSlotOutcome, VENDOR_RANGE_SQ, }; - use crate::test_scan::code_of; use lyracore_shared::constants::npc_flags::{BANKER, INNKEEPER, VENDOR}; /// BANK ACCESS: only a live BANKER-flagged NPC inside the interaction radius opens the bank. A @@ -609,17 +608,6 @@ mod tests { assert!(!inn_in_reach(false, INNKEEPER, true, 0.0)); // an innkeeper's corpse } - /// The bind reducer must CALL the gate. There is no `ReducerContext` harness in this crate, so - /// the call's presence is pinned by a source scan (the `bank_access_gate` precedent). - #[test] - fn gw_bind_home_consults_the_innkeeper_gate() { - let body = code_of(include_str!("../gw.rs"), "pub fn gw_bind_home("); - assert!( - body.contains("innkeeper_access_gate"), - "gw_bind_home must gate on an innkeeper in reach: {body}" - ); - } - /// Each purchase outcome must reach the reducer boundary as its own wire code, so the relay never /// has to read the prose. `Bought` is the only `Ok`. #[test] @@ -650,90 +638,6 @@ mod tests { ); } - /// The purchase names its banker, so it must validate that target through the shared NPC gate - /// (the vendor treatment) and charge before it hands the slot over. - #[test] - fn buy_bank_slot_validates_the_named_banker_and_charges_first() { - let body = code_of( - include_str!("economy.rs"), - "pub(crate) fn apply_buy_bank_slot(", - ); - assert!( - body.contains("npc_interaction_gate("), - "the named banker must go through the shared NPC-interaction gate" - ); - assert!( - body.contains("npc_flags::BANKER"), - "the named target must carry the BANKER flag" - ); - let debit = body - .find("player.money -= price") - .expect("the purse is charged"); - let grant = body - .find("player.bank_bag_slots += 1") - .expect("the slot count grows"); - assert!( - debit < grant, - "the copper must be taken before the slot is granted" - ); - } - - /// The banker/innkeeper search is a spatial query, so it must go through the partition-scoped, - /// grid-indexed helper — a whole-table read would see only the caller's own shard after a split. - /// Both no-named-NPC gates share one search, so this pins the shared one plus each delegation. - #[test] - fn the_unnamed_npc_gates_search_through_the_partition_scoped_helper() { - let src = include_str!("economy.rs"); - let body = code_of(src, "fn flagged_npc_in_reach_gate("); - assert!( - body.contains("crate::helpers::entities_near("), - "the proximity search must use `helpers::entities_near`" - ); - assert!( - body.contains("player.dead"), - "a dead player must not reach the bank or the innkeeper" - ); - for gate in [ - "pub(crate) fn bank_access_gate(", - "pub(crate) fn innkeeper_access_gate(", - ] { - assert!( - code_of(src, gate).contains("flagged_npc_in_reach_gate("), - "{gate} must delegate to the shared search, not grow a second copy" - ); - } - } - - /// This crate has no `ReducerContext` harness by design (`test_scan`'s doc comment / - /// playbook §7), so `apply_player_repair`'s actual gating/cost/durability-restore behavior - /// against real table state is verified live via the wire harness, not here — the same boundary - /// every other reducer in this module lives behind (see `combat/swing.rs`'s chokepoint tests for - /// the same disclosure). This pins the two invariants a source-text scan CAN catch: the debit - /// happens (and is persisted) before the durability restore loop — so a rolled-back transaction - /// never leaves a charge without a repair — and every collected target actually gets its - /// durability written back to its template's max, not just summed into the cost. - #[test] - fn apply_player_repair_debits_before_it_restores_every_target() { - let src = include_str!("economy.rs"); - let body = code_of(src, "pub(crate) fn apply_player_repair("); - let debit_at = body - .find("player.money -= total_cost;") - .expect("apply_player_repair must debit money before persisting"); - let persist_at = body - .find("entities.guid().update(player);") - .expect("the debited player must be persisted"); - let restore_at = body - .find("inst.durability = max_dur;") - .expect("apply_player_repair must restore durability to the template max"); - let restore_persist_at = body - .find("instances.guid().update(inst);") - .expect("the restored item must be persisted"); - assert!( - debit_at < persist_at && persist_at < restore_at && restore_at < restore_persist_at, - "expected debit → persist → restore → persist, in that order" - ); - } - /// BUYBACK RING FIFO EVICTION: the ring evicts its oldest entry once it already holds 12 (so the /// 13th sale pushes one out and the ring never grows past 12); below that it never evicts. #[test] diff --git a/module/src/items/inventory.rs b/module/src/items/inventory.rs index cf26359d3..9ef732c39 100644 --- a/module/src/items/inventory.rs +++ b/module/src/items/inventory.rs @@ -744,7 +744,6 @@ mod tests { bag_content_decompose, equip_slot, is_bank_slot, is_carried_slot, valid_dest_slot, valid_split_count, valid_split_dest_slot, BANK_SLOT_END_INCL, BANK_SLOT_START, }; - use crate::test_scan::code_of; /// BANK SLOT RANGE: exactly the 24 base bank slots (39..=62). The bank-bag ordinals just past them /// (63..=68) are not bank storage in this model. @@ -758,109 +757,6 @@ mod tests { } } - /// BANK ACCESS GATE WIRING: both mutation paths must consult `bank_access` when either - /// endpoint is a bank slot, or the bank becomes a portable 24-slot bag. There is no - /// `ReducerContext` harness in this crate, so the call's PRESENCE is pinned by a source scan; the - /// gate's own decision is asserted directly on `economy::banker_in_reach`. - #[test] - fn both_mutation_paths_gate_bank_endpoints_on_an_open_bank() { - let src = include_str!("inventory.rs"); - for signature in [ - "pub(crate) fn apply_item_move(", - "pub(crate) fn apply_item_split(", - ] { - let body = code_of(src, signature); - assert!( - body.contains("bank_access(ctx, player_guid)?"), - "`{signature}` must refuse a bank endpoint without an open bank" - ); - assert!( - body.contains("is_bank_slot(to_slot)"), - "`{signature}` must test its destination slot for bank space" - ); - } - } - - /// EQUIP ELIGIBILITY GATES: the one equipment branch must check all imported requirements before - /// the BoE/placement mutations, so every refusal preserves both item rows and binding state. - #[test] - fn equip_eligibility_gates_precede_all_item_mutations() { - let src = include_str!("inventory.rs"); - let body = code_of(src, "pub(crate) fn apply_item_move("); - for gate in [ - "eligibility_mask_allows(tmpl.allowed_class, player_class)", - "eligibility_mask_allows(tmpl.allowed_race, player_race)", - "meets_required_skill(tmpl.required_skill, tmpl.required_skill_rank, current_skill)", - "meets_required_reputation(", - "proficiency.can_equip(tmpl.class, tmpl.subclass)", - ] { - assert!(body.contains(gate), "equip path must retain `{gate}`"); - assert!( - body.find(gate).unwrap() < body.find("src.soulbound = true").unwrap(), - "`{gate}` must refuse before the BoE mutation" - ); - assert!( - body.find(gate).unwrap() < body.find("src.slot = to_slot").unwrap(), - "`{gate}` must refuse before placement changes" - ); - } - } - - /// ARMOR PROFICIENCY SOURCE: the equip Gate must derive proficiency from the Character's LEARNED - /// passives, so a level-40 trainer purchase is what unlocks plate. Reading the class alone would - /// hand a fresh Warrior a full plate set. There is no `ReducerContext` harness in this crate, so - /// the spellbook read is pinned by source scan; the derivation's answers are asserted directly - /// in `lyracore-shared` and in `rules`. - #[test] - fn the_equip_gate_derives_armor_proficiency_from_learned_passives() { - let src = include_str!("inventory.rs"); - let body = code_of(src, "pub(crate) fn apply_item_move("); - assert!( - body.contains("Proficiency::from_spellbook(") && body.contains("knows_spell("), - "the equip path must derive proficiency from this Character's spellbook" - ); - assert!( - !body.contains("class_ceiling("), - "the class ceiling belongs to the auction filter and must never gate an equip" - ); - } - - /// FREE-BANK-SLOT SEARCH SHAPE: like `first_free_backpack_slot`, `first_free_bank_slot` must - /// collect the owner's occupied slots into a set ONCE rather than re-scanning per candidate slot. - /// There is no `ReducerContext` harness in this crate, so the shape is pinned by a source scan. - #[test] - fn first_free_bank_slot_scans_the_owners_rows_once() { - let src = include_str!("inventory.rs"); - let body = code_of(src, "pub(crate) fn first_free_bank_slot("); - assert_eq!( - body.matches("by_owner_guid()").count(), - 1, - "first_free_bank_slot must collect the owner's rows once, not scan per candidate slot" - ); - } - - /// AUTO-BANK DIRECTION INFERENCE: `apply_auto_bank_item` decides deposit vs. withdraw from the - /// SOURCE slot alone. Pinned by source scan (no `ReducerContext` harness): a bank source resolves - /// against the carry-space search pair; anything else resolves against the bank search. - #[test] - fn apply_auto_bank_item_infers_direction_from_the_source_slot() { - let src = include_str!("inventory.rs"); - let body = code_of(src, "pub(crate) fn apply_auto_bank_item("); - assert!( - body.contains("is_bank_slot(slot)"), - "must branch on whether the source is a bank slot" - ); - assert!( - body.contains("first_free_backpack_slot(ctx, player_guid)") - && body.contains("first_free_bag_slot(ctx, player_guid)"), - "withdraw must fall back from the backpack to bag space, like loot auto-store" - ); - assert!( - body.contains("first_free_bank_slot(ctx, player_guid)"), - "deposit must resolve against the free-bank-slot search" - ); - } - /// CARRIED-SLOT PREDICATE: equipment, bag-equip, backpack, and bag-content slots are carried; the /// base bank range is owned but not carried, and so is every slot past it (the bank-bag region and /// the unaddressed tail). diff --git a/module/src/load.rs b/module/src/load.rs index e72d933f4..0d7ca5e21 100644 --- a/module/src/load.rs +++ b/module/src/load.rs @@ -311,65 +311,4 @@ mod tests { assert!(validate_occupancy_pct(f32::INFINITY).is_err()); assert!(validate_occupancy_pct(MAX_SANE_OCCUPANCY_PCT + 0.1).is_err()); } - - /// Call-site tripwire, same style as `region.rs`'s `the_reducer_routes_every_write_through_the_epoch_rule`: - /// the pure rules above are worthless if the reducer stops asking them, and neither reducer can - /// run without a live node — so a `.contains()` scan on the SOURCE is what a green suite has. - #[test] - fn both_reducers_route_every_write_through_the_operator_gate_and_the_ring() { - let src = include_str!("load.rs"); - // The file is laid out record_shard_load, then record_region_load, then the test module — - // so splitting on those three anchors, in order, isolates each reducer's own body. - let (_, after_shard_fn) = src - .split_once("pub fn record_shard_load") - .expect("still named record_shard_load"); - let (shard_body, after_region_fn) = after_shard_fn - .split_once("pub fn record_region_load") - .expect("still named record_region_load"); - let (region_body, _) = after_region_fn - .split_once("#[cfg(test)]") - .expect("the test module still follows"); - - let strip = |s: &str| -> String { - s.lines() - .filter(|l| !l.trim_start().starts_with("//")) - .collect::>() - .join("\n") - }; - let shard_code = strip(shard_body); - let region_code = strip(region_body); - - assert!( - shard_code.contains("require_operator(ctx)?"), - "record_shard_load must stay operator-gated" - ); - assert!( - shard_code.contains("validate_occupancy_pct("), - "record_shard_load must validate occupancy" - ); - assert!( - shard_code.contains("ring_evict("), - "record_shard_load must ring-evict before inserting" - ); - assert!( - shard_code.contains("r.gateway_key == gateway_key"), - "record_shard_load must scope the ring by gateway_key too (issue #308) — filtering by \ - shard alone lets one gateway's insert evict every OTHER gateway's rows" - ); - assert!( - shard_code.contains("realm_wide_sessions("), - "record_shard_load must fold every gateway's latest sample into the materialized \ - realm-wide total (issue #308's actual Done-when) — keying the ring by gateway_key \ - alone leaves summation a manual step" - ); - - assert!( - region_code.contains("require_operator(ctx)?"), - "record_region_load must stay operator-gated" - ); - assert!( - region_code.contains("ring_evict("), - "record_region_load must ring-evict before inserting" - ); - } } diff --git a/module/src/loot/mod.rs b/module/src/loot/mod.rs index 6407499f5..fc02cf0a1 100644 --- a/module/src/loot/mod.rs +++ b/module/src/loot/mod.rs @@ -1486,32 +1486,4 @@ mod tests { assert!(!money_is_grouped(&[7])); assert!(money_is_grouped(&[7, 8])); } - - use crate::test_scan::code_of; - - /// `purge_corpse_residue` is the ONE helper `kill_creature` calls to close the - /// pickpocket/kill slot collision, and the issue explicitly asks for it to cover BOTH residue - /// tables (`game_corpse_loot` — the actual colliding rows — and `game_corpse_loot_eligible`, the - /// pre-existing work-item-267 reused-guid residue). Losing either `by_corpse().filter(&guid)` - /// sweep silently reopens one of the two residue classes on a reused/live-then-killed guid. - #[test] - fn purge_corpse_residue_sweeps_both_corpse_loot_tables() { - let body = code_of( - include_str!("mod.rs"), - "pub(crate) fn purge_corpse_residue(ctx: &ReducerContext, guid: u64) {", - ); - assert!( - body.contains("ctx.db.game_corpse_loot();") && body.contains("loot.by_corpse().filter(&guid)"), - "`purge_corpse_residue` no longer sweeps `game_corpse_loot` by corpse guid — a leftover \ - pickpocket row can collide with a freshly-rolled kill-drop slot again (issue #358). \ - Body was:\n{body}" - ); - assert!( - body.contains("ctx.db.game_corpse_loot_eligible();") - && body.contains("eligible.by_corpse().filter(&guid)"), - "`purge_corpse_residue` no longer sweeps `game_corpse_loot_eligible` by corpse guid — \ - the work-item-267 reused-guid residue (a departed group inheriting a stale eligibility \ - snapshot) would come back. Body was:\n{body}" - ); - } } diff --git a/module/src/loot/rolls.rs b/module/src/loot/rolls.rs index 0fee0f742..9354bbbb5 100644 --- a/module/src/loot/rolls.rs +++ b/module/src/loot/rolls.rs @@ -1127,84 +1127,4 @@ mod tests { ); assert!(pick_roll_winner(&[]).is_none()); } - - // ---- The realm-core loot-roll plane ---- - // - // A reducer body needs a live `ReducerContext`, so none of these can be EXECUTED by a test in - // this crate — exactly why they are scanned, the same technique `group.rs` uses for its own - // realm-plane reducers (the review). - - use crate::test_scan::code_of; - - /// **The operator gate is the entire authorization of the realm loot-roll plane.** - /// - /// All three reducers below take a corpse/roll identity or an actor guid as an ARGUMENT rather - /// than deriving it from `ctx.sender()` — realm-core has no live entity to derive one from, and - /// the two world-shard-only ones (`settle_loot_roll`/`clear_promoted_loot_roll`) grant an item or - /// delete roll rows outright. The gate is the only thing between an arbitrary connection and - /// forging a roll outcome or wiping another group's live roll. - #[test] - fn every_realm_loot_reducer_is_operator_gated() { - for f in [ - "pub fn realm_loot_op(", - "pub fn settle_loot_roll(", - "pub fn clear_promoted_loot_roll(", - ] { - let body = code_of(include_str!("rolls.rs"), f); - let normalized: String = body.split_whitespace().collect::>().join(" "); - assert!( - normalized.starts_with("{ crate::helpers::require_operator(ctx)?;"), - "`{f}` no longer OPENS with the operator gate — a gate that is present but not the \ - FIRST statement (wrapped in `if false`, `let _ =`, or preceded by an early return) \ - is no gate. Body was:\n{body}" - ); - } - } - - /// [`settle_roll_grant`] must check `withheld` BEFORE granting anything — without it, a - /// wrong-shard `settle_loot_roll` call (the gateway fans a `ROLL_WON` settle to EVERY connected - /// shard, since it does not know in advance which one holds the corpse) could steal an ordinary, - /// un-rolled `game_corpse_loot` row that merely happens to share a `(corpse_guid, slot)` number - /// with the resolved roll on another shard. - #[test] - fn settle_roll_grant_checks_withheld_before_granting() { - let body = code_of(include_str!("rolls.rs"), "pub(crate) fn settle_roll_grant("); - let withheld_at = body - .find("if !row.withheld {") - .expect("settle_roll_grant no longer gates on `row.withheld` — see this fn's own doc"); - let grant_at = body - .find("crate::items::grant_item_property(") - .expect("settle_roll_grant no longer calls grant_item_property"); - assert!( - withheld_at < grant_at, - "the `withheld` guard must run BEFORE `grant_item` — a guard added AFTER the grant \ - already happened is not a guard. Body was:\n{body}" - ); - } - - /// The acceptance test for this: `group::remove_member`'s disband branch must call - /// `force_resolve_rolls_for_disband` UNCONDITIONALLY and BEFORE it tears the group row down. - /// That ordering — not a `Plane` flag, which does not exist on `remove_member` — is what makes a - /// disbanding party's live rolls resolve on the SAME database, in the SAME transaction, as the - /// membership change: whichever database `remove_member` executes on (a world shard, unsharded; - /// REALM-CORE, sharded) is also the database its `game_loot_roll` rows are the truth on. Mutation- - /// check: deleting the call, or moving it after the group-row delete, must turn this red. - #[test] - fn disband_resolves_live_rolls_before_it_tears_the_group_row_down() { - let body = code_of(include_str!("../group.rs"), "pub(crate) fn remove_member("); - let call = "crate::loot::force_resolve_rolls_for_disband(ctx, &all_guids, survivor);"; - let call_at = body - .find(call) - .unwrap_or_else(|| panic!("`remove_member` no longer force-resolves live rolls on disband (issue #50). Body was:\n{body}")); - let delete_at = body - .find("ctx.db.game_group().group_id().delete(group_id);") - .expect("`remove_member` no longer deletes the group row on disband"); - assert!( - call_at < delete_at, - "force_resolve_rolls_for_disband must run BEFORE the group row is torn down (issue #50) \ - — resolving after the delete would still be correct data-wise, but a reviewer relying on \ - \"same transaction, no mirror\" should not have to re-derive that from execution order \ - every time this function changes. Body was:\n{body}" - ); - } } diff --git a/module/src/mail.rs b/module/src/mail.rs index b1b92937d..5d37f5fac 100644 --- a/module/src/mail.rs +++ b/module/src/mail.rs @@ -745,7 +745,6 @@ pub fn realm_mail_return( #[cfg(test)] mod tests { use super::*; - use crate::test_scan::read_scanned; #[test] fn taking_money_is_refused_for_a_mail_the_caller_is_not_the_recipient_of() { @@ -934,66 +933,6 @@ mod tests { assert_eq!(payment.money, 250); } - /// `game_mail` inserts in `src`: the table call or a handle bound from it, followed by - /// `.insert(` across any whitespace, so an insert split over lines still counts. - fn game_mail_inserts(src: &str) -> usize { - crate::test_scan::raw_table_reads(src, &["game_mail"], |code, at| { - code[at..].trim_start().starts_with(".insert(") - }) - .len() - } - - #[test] - fn the_mail_insert_scan_sees_split_and_handle_inserts_and_skips_comments() { - assert_eq!( - game_mail_inserts( - "fn f() {\n ctx.db\n .game_mail()\n .insert(row);\n}" - ), - 1 - ); - assert_eq!( - game_mail_inserts("let mails = ctx.db.game_mail();\nmails.insert(row);"), - 1 - ); - assert_eq!(game_mail_inserts("// ctx.db.game_mail().insert(row);"), 0); - assert_eq!(game_mail_inserts("ctx.db.game_mail().id().update(row);"), 0); - assert_eq!( - game_mail_inserts("ctx.db.game_mail_escrow().insert(row);"), - 0 - ); - } - - /// Every mail row goes through `insert_letter`, so no letter skips the header it stamps. The - /// one other writer is `character_owned!`'s Transfer remint, which inserts through - /// `ctx.db.$table()` and so never spells the table name. - #[test] - fn every_mail_row_is_created_by_insert_letter() { - let mut elsewhere = Vec::new(); - let mut total = 0; - for file in crate::tripwires::character_owned_tripwire::scanned_files() { - let src = std::fs::read_to_string(&file).expect("a scanned file is readable"); - let found = game_mail_inserts(&src); - total += found; - if found > 0 && !file.ends_with("module/src/mail.rs") { - elsewhere.push(file.display().to_string()); - } - } - assert!( - elsewhere.is_empty(), - "these files insert game_mail rows directly: {elsewhere:?}. Build a `Letter` and call \ - `mail::insert_letter` instead" - ); - let own = game_mail_inserts(&code_of( - include_str!("mail.rs"), - "pub(crate) fn insert_letter(", - )); - assert_eq!( - (own, total), - (1, 1), - "`insert_letter` must hold the only game_mail insert" - ); - } - #[test] fn a_credited_purse_saturates_rather_than_wrapping() { assert_eq!(credited(100, 30), 130); @@ -1019,48 +958,6 @@ mod tests { } } - #[test] - fn no_mail_code_path_iterates_the_spatial_gameobject_table() { - for path in [ - "module/src/mail.rs", - "gateway/src/stdb/reads/mail.rs", - "gateway/src/world/mail.rs", - "gateway/src/world/handlers/mail.rs", - ] { - let table = format!("{}{}", "game_gameobject", "()"); - let scan = format!("{}{}", ".iter", "()"); - let src = read_scanned(path).expect("module/ and gateway/ ship in every checkout"); - for (n, line) in src.lines().enumerate() { - let code = line.split("//").next().unwrap_or(line); - assert!( - !(code.contains(&table) && code.contains(&scan)), - "{path}:{} iterates the SPATIAL gameobject table. Sharding makes that a silent \ - subset — the client names the mailbox guid, so resolve it by PK \ - (`game_gameobject().guid().find(..)`) and range-check against the player's own \ - entity, the shape `gameobject::usable_go` uses.", - n + 1 - ); - } - } - } - - #[test] - fn the_single_database_money_paths_never_reach_the_escrow() { - for signature in [ - "pub(crate) fn apply_send(", - "pub(crate) fn apply_take_money(", - "pub(crate) fn apply_take_item(", - ] { - let body = code_of(include_str!("mail.rs"), signature); - assert!( - !body.contains("escrow"), - "`{signature}` is the ONE-TRANSACTION plane. The escrow is the mechanism for the \ - case where a transaction cannot span the two databases; here one can. Body \ - was:\n{body}" - ); - } - } - #[test] fn a_returned_mail_goes_back_unread_unpriced_and_marked_returned_from_now() { let priced = Mail { @@ -1161,47 +1058,4 @@ mod tests { "cmangos Mail.cpp:299-313 counts the 30 days from the delivery" ); } - - #[test] - fn every_action_a_recipient_takes_looks_the_mail_up_by_its_delivery() { - for signature in [ - "pub(crate) fn mail_money(", - "pub(crate) fn mail_item(", - "pub(crate) fn apply_take_item(", - "pub(crate) fn apply_return(", - "pub(crate) fn apply_mark_read(", - "pub(crate) fn apply_delete(", - ] { - let body = crate::test_scan::shape_of(include_str!("mail.rs"), signature); - assert!( - body.contains("delivered_mail(ctx, mail_id)") && !body.contains(".find("), - "`{signature}` must find the mail through `delivered_mail`, so an undelivered mail \ - cannot be read, deleted, taken or returned. Body was:\n{body}" - ); - } - } - - use crate::test_scan::code_of; - - #[test] - fn the_realm_mail_write_reducers_are_operator_gated() { - for signature in [ - "pub fn realm_mail_mark_read(", - "pub fn realm_mail_delete(", - "pub fn realm_mail_return(", - "pub fn realm_mail_send(", - "pub fn realm_mail_take_money(", - "pub fn realm_mail_take_item(", - "pub fn realm_mail_item_room(", - ] { - let body = code_of(include_str!("mail.rs"), signature); - let normalized: String = body.split_whitespace().collect::>().join(" "); - assert!( - normalized.starts_with("{ crate::helpers::require_operator(ctx)?;"), - "`{signature}` no longer OPENS with the operator gate. It takes the caller's guid \ - as an argument, so the gate is the only thing between an arbitrary connection and \ - mutating anybody's mailbox in the realm. Body was:\n{body}" - ); - } - } } diff --git a/module/src/mail_escrow.rs b/module/src/mail_escrow.rs index 19d698380..6194780e8 100644 --- a/module/src/mail_escrow.rs +++ b/module/src/mail_escrow.rs @@ -1186,7 +1186,6 @@ pub fn reap_mail_escrows(ctx: &ReducerContext, _schedule: MailEscrowReaperSchedu #[cfg(test)] mod tests { use super::*; - use crate::test_scan::code_of; #[test] fn a_replayed_fence_is_a_no_op_and_a_reused_id_is_refused() { assert_eq!( @@ -1278,161 +1277,4 @@ mod tests { assert_eq!(evidence_of(false), DeliveryEvidence::Unknown); assert_eq!(evidence_of(true), DeliveryEvidence::Attested); } - #[test] - fn the_production_adapter_is_the_pass_through_the_harness_assumes() { - let src = include_str!("mail_escrow.rs"); - for (signature, want) in [ - ( - "impl EscrowLedger for CtxDb<'_> {", - "{ fn escrow(&self, escrow_id: u64) -> Option { \ - self.ctx.db.game_mail_escrow().escrow_id().find(escrow_id) } fn \ - file_escrow(&mut self, row: MailEscrow) { \ - self.ctx.db.game_mail_escrow().insert(row); } fn attest_delivery(&mut self, \ - escrow_id: u64) { if let Some(row) = self.escrow(escrow_id) { self.ctx .db \ - .game_mail_escrow() .escrow_id() .update(MailEscrow { delivered: true, ..row \ - }); } } fn delete_escrow(&mut self, escrow_id: u64) { \ - self.ctx.db.game_mail_escrow().escrow_id().delete(escrow_id); } fn \ - arm_reaper(&mut self) { let sched = \ - self.ctx.db.game_mail_escrow_reaper_schedule(); if \ - sched.iter().next().is_none() { sched.insert(MailEscrowReaperSchedule { \ - scheduled_id: 0, scheduled_at: ScheduleAt::Interval(TimeDuration::from_micros( \ - MAIL_ESCROW_REAP_INTERVAL_MICROS as i64, )), }); } } fn now_micros(&self) -> \ - i64 { self.ctx.timestamp.to_micros_since_unix_epoch() } }", - ), - ( - "impl FenceSink for CtxDb<'_> {", - "{ fn purse(&self, sender_guid: u64) -> Option { \ - crate::helpers::acting_entity_by_guid(self.ctx, sender_guid).map(|e| e.money) \ - } 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); } } fn detach_item( &mut \ - self, sender_guid: u64, item_guid: u64, ) -> Result { crate::mail::detach_item(self.ctx, sender_guid, item_guid) } }", - ), - ( - "impl TakeFenceSink for CtxDb<'_> {", - "{ fn mail(&self, mail_id: u64) -> Option<(u64, u32)> { \ - crate::mail::mail_money(self.ctx, mail_id) } fn clear_mail_money(&mut self, \ - mail_id: u64) { crate::mail::clear_mail_money(self.ctx, mail_id); } fn \ - mail_item(&self, mail_id: u64) -> Option<(u64, crate::items::ItemSnapshot)> { \ - crate::mail::mail_item(self.ctx, mail_id) } fn clear_mail_item(&mut self, \ - mail_id: u64) { crate::mail::clear_mail_item(self.ctx, mail_id); } }", - ), - ( - "impl PayoutSink for CtxDb<'_> {", - "{ fn receipt(&self, escrow_id: u64) -> Option { \ - self.ctx.db.game_mail_delivery().escrow_id().find(escrow_id) } fn \ - credit_purse(&mut self, payee_guid: u64, amount: u32) -> bool { let Some(mut \ - e) = crate::helpers::acting_entity_by_guid(self.ctx, payee_guid) else { return \ - false; }; e.money = crate::mail::credited(e.money, amount); \ - self.ctx.db.game_world_entity().guid().update(e); true } fn grant_item( &mut \ - self, payee_guid: u64, item: &crate::items::ItemSnapshot, ) -> Result<(), \ - String> { crate::mail::grant_snapshot(self.ctx, payee_guid, item) } fn \ - file_receipt(&mut self, row: MailDelivery) { \ - self.ctx.db.game_mail_delivery().insert(row); } fn now_micros(&self) -> i64 { \ - self.ctx.timestamp.to_micros_since_unix_epoch() } }", - ), - ( - "impl ReapSink for CtxDb<'_> {", - "{ fn escrows(&self) -> Vec<(u64, u64, i64, bool)> { self.ctx .db \ - .game_mail_escrow() .iter() .map(|e| (e.escrow_id, e.sender_guid, \ - e.created_micros, e.delivered)) .collect() } }", - ), - ( - "impl DeliverySink for CtxDb<'_> {", - "{ fn receipt(&self, escrow_id: u64) -> Option { \ - self.ctx.db.game_mail_delivery().escrow_id().find(escrow_id) } fn \ - deliver(&mut self, letter: crate::mail::Letter) -> u64 { \ - crate::mail::insert_letter(self.ctx, letter) } fn priced_mail(&self, mail_id: \ - u64) -> Option { self.ctx .db .game_mail() .id() .find(mail_id) \ - .map(|m| PricedMail { recipient_guid: m.recipient_guid, cod: m.cod, \ - delivered: m.is_delivered(self.ctx.timestamp), }) } fn settle_cod(&mut \ - self, mail_id: u64) { crate::mail::clear_mail_cod(self.ctx, mail_id); } fn \ - file_receipt(&mut self, row: MailDelivery) { \ - self.ctx.db.game_mail_delivery().insert(row); } fn now_micros(&self) -> i64 { \ - self.ctx.timestamp.to_micros_since_unix_epoch() } }", - ), - ( - "pub fn realm_mail_fence(", - "{ require_operator(ctx)?; let sender_guid = crate::account_ownership::require_actor(ctx, request_actor)?; apply_fence( &mut CtxDb { ctx }, escrow_id, \ - sender_guid, Draft { recipient_guid, subject, body, money, postage, cod, }, \ - item_guid, mail_id, same_account, ) }", - ), - ( - "pub fn realm_mail_commit(", - "{ require_operator(ctx)?; let sender_guid = crate::account_ownership::require_actor(ctx, request_actor)?; apply_commit( &mut CtxDb { ctx }, escrow_id, \ - sender_guid, &Draft { recipient_guid, subject, body, money, postage: 0, cod, \ - }, &crate::items::ItemSnapshot { entry: item_entry, stack_count: \ - item_stack_count, durability: item_durability, enchant_id: item_enchant_id, \ - soulbound: item_soulbound, random_property_id, item_text_id, }, cod_mail_id, \ - delivery_delay_secs, RewardHeader::from_columns(sender_kind, sender_entry, \ - mail_template_id)?, ) }", - ), - ( - "pub fn realm_mail_take_money_fence(", - "{ require_operator(ctx)?; let payee_guid = crate::account_ownership::require_actor(ctx, request_actor)?; apply_take_fence( &mut CtxDb { ctx }, escrow_id, \ - payee_guid, mail_id, expect_money, ) }", - ), - ( - "pub fn realm_mail_payout(", - "{ require_operator(ctx)?; let payee_guid = crate::account_ownership::require_actor(ctx, request_actor)?; apply_payout(&mut CtxDb { ctx }, escrow_id, \ - payee_guid, mail_id, amount) }", - ), - ( - "pub fn realm_mail_take_item_fence(", - "{ require_operator(ctx)?; let payee_guid = crate::account_ownership::require_actor(ctx, request_actor)?; apply_take_item_fence( &mut CtxDb { ctx }, \ - escrow_id, payee_guid, mail_id, expect_entry, ) }", - ), - ( - "pub fn realm_mail_item_payout(", - "{ require_operator(ctx)?; let payee_guid = crate::account_ownership::require_actor(ctx, request_actor)?; apply_item_payout( &mut CtxDb { ctx }, escrow_id, \ - payee_guid, mail_id, &crate::items::ItemSnapshot { entry: item_entry, \ - stack_count: item_stack_count, durability: item_durability, enchant_id: \ - item_enchant_id, soulbound: item_soulbound, random_property_id, item_text_id, }, ) }", - ), - ( - "pub fn realm_mail_confirm_delivery(", - "{ require_operator(ctx)?; require_escrow_actor(ctx, escrow_id, request_actor)?; apply_confirm(&mut CtxDb { ctx }, escrow_id) }", - ), - ( - "pub fn realm_mail_settle(", - "{ require_operator(ctx)?; require_escrow_actor(ctx, escrow_id, request_actor)?; apply_settle(&mut CtxDb { ctx }, escrow_id) }", - ), - ( - "pub fn reap_mail_escrows(", - "{ if ctx.sender() != ctx.database_identity() { return; } apply_reap(&mut CtxDb \ - { ctx }); }", - ), - ] { - let want = want.split_whitespace().collect::>().join(" "); - assert_eq!( - crate::test_scan::shape_of(src, signature), - want, - "`{signature}` is no longer the exact pass-through `mod harness` assumes it is. \ - The harness runs the shared body underneath this layer with a fake substituted \ - for every line here, and nothing else covers an edit to it." - ); - } - } - #[test] - fn the_mail_escrow_reducers_are_operator_gated() { - for signature in [ - "pub fn realm_mail_fence(", - "pub fn realm_mail_commit(", - "pub fn realm_mail_take_money_fence(", - "pub fn realm_mail_payout(", - "pub fn realm_mail_take_item_fence(", - "pub fn realm_mail_item_payout(", - "pub fn realm_mail_confirm_delivery(", - "pub fn realm_mail_settle(", - ] { - let body = code_of(include_str!("mail_escrow.rs"), signature); - let normalized: String = body.split_whitespace().collect::>().join(" "); - assert!( - normalized.starts_with("{ require_operator(ctx)?;"), - "`{signature}` no longer OPENS with the operator gate. Body was:\n{body}" - ); - } - } } diff --git a/module/src/mail_text.rs b/module/src/mail_text.rs index c530475d2..707db9097 100644 --- a/module/src/mail_text.rs +++ b/module/src/mail_text.rs @@ -363,39 +363,4 @@ mod tests { assert_eq!(letters_after_release(u32::MAX), Some(u32::MAX)); assert_eq!(letters_after_copy(u32::MAX), u32::MAX); } - - #[test] - fn the_realm_mail_copy_text_reducer_is_operator_gated() { - let body = - crate::test_scan::code_of(include_str!("mail_text.rs"), "pub fn realm_mail_copy_text("); - let normalized: String = body.split_whitespace().collect::>().join(" "); - assert!( - normalized.starts_with("{ crate::helpers::require_operator(ctx)?;"), - "`realm_mail_copy_text` no longer OPENS with the operator gate. Body was:\n{body}" - ); - } - - #[test] - fn the_gw_mail_grant_letter_reducer_is_operator_gated() { - let body = - crate::test_scan::code_of(include_str!("mail_text.rs"), "pub fn gw_mail_grant_letter("); - let normalized: String = body.split_whitespace().collect::>().join(" "); - assert!( - normalized.starts_with("{ crate::helpers::require_operator(ctx)?;"), - "`gw_mail_grant_letter` no longer OPENS with the operator gate. Body was:\n{body}" - ); - } - - #[test] - fn the_realm_mail_mark_letter_granted_reducer_is_operator_gated() { - let body = crate::test_scan::code_of( - include_str!("mail_text.rs"), - "pub fn realm_mail_mark_letter_granted(", - ); - let normalized: String = body.split_whitespace().collect::>().join(" "); - assert!( - normalized.starts_with("{ crate::helpers::require_operator(ctx)?;"), - "`realm_mail_mark_letter_granted` no longer OPENS with the operator gate. Body was:\n{body}" - ); - } } diff --git a/module/src/motion.rs b/module/src/motion.rs index 10d99ee0f..ceccdf25b 100644 --- a/module/src/motion.rs +++ b/module/src/motion.rs @@ -478,10 +478,10 @@ mod tests { // ---- A model of the queue, built out of the SAME pure fns the reducers execute -------------- // - // The module crate has no `ReducerContext` harness by design (playbook §7), so the DB wiring is - // pinned by the source scans below and the ALGORITHM is exercised here against an in-memory - // stand-in for the two tables. Every decision in the model is a call into `coalesce` / - // `tick_action`; the model itself only moves rows between two maps, exactly as the reducers do. + // The module crate has no `ReducerContext` harness by design (playbook §7), so the ALGORITHM is + // exercised here against an in-memory stand-in for the two tables. Every decision in the model is a + // call into `coalesce` / `tick_action`; the model itself only moves rows between two maps, + // exactly as the reducers do. #[derive(Default)] struct Model { @@ -662,77 +662,4 @@ mod tests { "one write per MOVER per firing, all inside the single tick transaction" ); } - - // ---- Source pins: the DB wiring no pure test in this crate can reach ------------------------ - - #[test] - fn the_tick_gates_every_publish_on_the_liveness_check() { - let body = crate::test_scan::code_of( - include_str!("motion.rs"), - "pub fn publish_motion(ctx: &ReducerContext, _schedule: MotionPublishSchedule) {", - ); - assert!( - body.contains("ctx.sender() != ctx.database_identity()"), - "the tick must stay scheduler-only. Body was:\n{body}" - ); - let drain = crate::test_scan::code_of( - include_str!("motion.rs"), - "pub(crate) fn publish_staged(ctx: &ReducerContext) {", - ); - assert!( - drain.contains("tick_action(entities.guid().find(row.guid).is_some())"), - "every queued row must go through `tick_action` with a REAL liveness probe — a \ - publish that skips it resurrects motion rows for departed entities. Body was:\n{body}" - ); - assert_eq!( - drain.matches("publish(ctx, &row)").count(), - 1, - "exactly one publish call, and it is the `TickAction::Publish` arm. Body was:\n{drain}" - ); - assert!( - drain.contains("pending.guid().delete(row.guid)"), - "the queue must be drained whatever the action, or a stale row lives forever. Body \ - was:\n{drain}" - ); - } - - #[test] - fn the_write_path_routes_every_decision_through_coalesce() { - let body = - crate::test_scan::code_of(include_str!("motion.rs"), "pub(crate) fn queue_motion("); - assert!( - body.contains("coalesce(prev.opcode, opcode)"), - "the staged-vs-incoming decision must be `coalesce`'s, not re-derived inline. Body \ - was:\n{body}" - ); - assert_eq!( - body.matches("publish(ctx, &prev)").count(), - 1, - "the ONLY inline publish on the movement path is the FlushThenReplace arm — any other \ - one reinstates the per-packet subscription sweep this item removes. Body was:\n{body}" - ); - assert!( - !body.contains("game_entity_motion()"), - "`movement_update`'s path must never touch the PUBLIC relay table directly — it stages \ - into the private one and lets `publish` mint `seq`. Body was:\n{body}" - ); - } - - #[test] - fn the_staging_table_is_private() { - let src = include_str!("motion.rs"); - // Assembled at run time: a contiguous literal would itself look like a `#[table(` attribute - // to `tripwires::character_owned_tripwire::extract_tables`, which text-scans this tree. - let needle = format!("{}{}", "#[table", "(accessor = game_entity_motion_pending"); - let attr = src - .find(&needle) - .expect("the staging table's own attribute"); - let end = src[attr..].find(")]").expect("attribute closes") + attr; - assert!( - !src[attr..end].contains("public"), - "`game_entity_motion_pending` MUST stay private — a subscriber on it reinstates the \ - per-transaction subscription sweep (#461's entire premise). Attribute was: {}", - &src[attr..end] - ); - } } diff --git a/module/src/mount.rs b/module/src/mount.rs index 74d67233f..58121bc11 100644 --- a/module/src/mount.rs +++ b/module/src/mount.rs @@ -339,157 +339,6 @@ mod tests { ); } - /// Both ctx-bound entry points refuse a player in a taxi flight BEFORE reading or writing - /// anything — `taxi.rs` owns `mount_display_id` for the whole flight, and the two mechanics share - /// the field. A scan tripwire because the guard is the first statement of a reducer-bound fn and - /// there is no `ReducerContext` harness in this crate. - #[test] - fn taxi_flight_is_guarded_before_any_mount_write() { - for signature in ["pub(crate) fn recompute_mount(", "pub(crate) fn dismount("] { - let body = crate::test_scan::shape_of(include_str!("mount.rs"), signature); - assert!( - body.starts_with("{ if crate::taxi::is_in_flight(ctx, guid) { return; }"), - "`{signature}` must refuse a taxi passenger before touching mount state — the taxi \ - flight owns `mount_display_id` and its own aura-free presentation. Body was:\n{body}" - ); - } - } - - /// The speed half of the recompute: `run_speed_mult_bp` is derived from the SAME - /// `combat::effective_move_speed` fold the anti-cheat movement check reads (base 1.0, so the field - /// carries the exact multiplier), in the SAME read-modify-write as the display — so a mount/dismount - /// relays through the EXISTING `run_speed_mult_bp` subscription diff with no gateway change, and - /// never writes when NEITHER field actually changed. Scan-pinned like the taxi guard above; the - /// FOLD's sense (mounted vs not, base-plus-buff) is asserted directly on `combat`'s - /// `mounted_move_pct` pure core, not re-derived here. - #[test] - fn recompute_mount_folds_effective_speed_into_run_speed_mult_bp() { - let body = - crate::test_scan::code_of(include_str!("mount.rs"), "pub(crate) fn recompute_mount("); - assert!( - body.contains("crate::combat::effective_move_speed(ctx, guid, 1.0)"), - "`recompute_mount` must derive `run_speed_mult_bp` from the SAME mounted-speed fold the \ - anti-cheat movement check reads, or the wire relay and the server's own speed check can \ - disagree. Body was:\n{body}" - ); - assert!( - body.contains("e.mount_display_id == display && e.run_speed_mult_bp == speed_bp"), - "`recompute_mount` must skip the write when NEITHER the display NOR the speed changed, or \ - a redundant write relays a spurious VALUES / force-run-speed update. Body was:\n{body}" - ); - } - - /// `dismount` drops the WHOLE mount spell, not just the `A_MOUNTED` row, so the paired - /// mounted-speed effect can never survive as an orphaned buff — then converges on the recompute - /// rather than clearing the field by hand. - #[test] - fn dismount_removes_the_whole_mount_spell_then_recomputes() { - let body = crate::test_scan::shape_of(include_str!("mount.rs"), "pub(crate) fn dismount("); - assert!( - body.contains("a.spell_id == spell_id"), - "`dismount` must delete every aura row of the active mount spell, or the mounted-speed \ - effect outlives the mount. Body was:\n{body}" - ); - assert!( - body.contains("recompute_mount(ctx, guid);"), - "`dismount` must converge on `recompute_mount` instead of writing the projection \ - directly. Body was:\n{body}" - ); - } - - /// The end-to-end cancel seam (story 24): `CMSG_CANCEL_AURA` lands on `gw_cancel_aura`, which - /// calls `do_cancel_aura`, which collects [`mount_aura_moves_mount`] alongside the vitals/sheet - /// predicates and recomputes the projection after the deletes. There is no `ReducerContext` - /// harness in this crate, so the chain's PRESENCE is pinned by scan; the SENSE of the recompute - /// is asserted directly on [`projected_mount_display`] above. - #[test] - fn cancel_aura_reaches_the_mount_recompute() { - let gw = crate::test_scan::code_of(include_str!("gw.rs"), "pub fn gw_cancel_aura("); - assert!( - gw.contains("crate::spell::do_cancel_aura(ctx, actor_guid, spell_id)"), - "`gw_cancel_aura` no longer routes to the cancel core. Body was:\n{gw}" - ); - let cancel = crate::test_scan::code_of( - include_str!("spell/scheduler.rs"), - "pub(crate) fn do_cancel_aura(", - ); - assert!( - cancel.contains("crate::mount::mount_aura_moves_mount(a.eff_kind, a.eff_p0)") - && cancel.contains("crate::mount::recompute_mount(ctx, player_guid);"), - "`do_cancel_aura` must collect the mount predicate while deleting and recompute the \ - projection afterwards, or right-clicking the mount buff off leaves a stuck model. \ - Body was:\n{cancel}" - ); - } - - /// DEATH DISMOUNTS. A rider who dies must come off the horse, and does so without any - /// mount-specific code in the death path: `kill_player` sheds the auras vanilla removes on death, - /// the mount aura is an ordinary cancelable one so it goes with them, and the shed collects - /// [`mount_aura_moves_mount`] and converges on [`recompute_mount`] — which clears BOTH halves of - /// the projection, the display and the folded run speed. The END STATE is asserted directly on the - /// pure projection above (an aura set with no `A_MOUNTED` row projects display 0); this pins the - /// chain that gets there, since the crate has no `ReducerContext` harness. - #[test] - fn a_dead_rider_is_dismounted_by_the_death_shed() { - let death = crate::test_scan::code_of( - include_str!("combat/death.rs"), - "pub(crate) fn kill_player(", - ); - assert!( - death.contains("crate::spell::remove_auras_on_death(ctx, victim_guid)"), - "`kill_player` must shed the dying player's auras — without it a corpse keeps riding. \ - Body was:\n{death}" - ); - let collect = crate::test_scan::code_of( - include_str!("spell/control.rs"), - "pub(crate) fn remove_auras_on_death(", - ); - assert!( - collect.contains("shed_auras(ctx, unit_guid, shed)"), - "the death shed must route its collected rows through the shared removal tail, which is \ - what recomputes the Mount Projection. Body was:\n{collect}" - ); - let shed = crate::test_scan::code_of( - include_str!("spell/control.rs"), - "pub(crate) fn shed_auras(", - ); - assert!( - shed.contains("crate::mount::mount_aura_moves_mount(a.eff_kind, a.eff_p0)") - && shed.contains("crate::mount::recompute_mount(ctx, unit_guid);"), - "the shared removal tail must collect the mount predicate while deleting and recompute \ - the projection afterwards, or a dead rider keeps a stuck mount display and mounted run \ - speed. Body was:\n{shed}" - ); - // The end state the chain converges on: no mount aura left → display 0 (and, through the same - // recompute, the unmounted run-speed multiplier). - let after_death: [(u8, i32); 0] = []; - assert_eq!(projected_mount_display(after_death), 0); - } - - /// The apply half: `aura_apply` is the single `game_aura` insertion boundary, so a mount becomes - /// active exactly there. It dismounts a DIFFERENT active mount first (story 28) and recomputes - /// after the row is placed. `E_DISMOUNT` resolves through the same shared operation. - #[test] - fn apply_and_dismount_effect_use_the_shared_operation() { - let targeting = include_str!("spell/cast/targeting.rs"); - let apply = crate::test_scan::code_of(targeting, "pub(crate) fn aura_apply("); - assert!( - apply.contains("crate::mount::dismount(ctx, target_guid);"), - "`aura_apply` must dismount a prior land mount before placing a new one. Body was:\n{apply}" - ); - assert!( - apply.contains("crate::mount::recompute_mount(ctx, target_guid);"), - "`aura_apply` must recompute the mount projection after placing the aura, or the frozen \ - display never reaches the client. Body was:\n{apply}" - ); - assert!( - targeting.contains( - "crate::mount::dismount(ctx, target_guid);\n EffectHit::none()" - ), - "the `E_DISMOUNT` arm must call the one shared dismount operation" - ); - } - /// The riding rank tiers (stories 13/14), asserted on the boundary the gate actually compares: /// `min_skill` is inclusive, so 74 fails a rank-75 mount and 75 rides it, 149 fails a rank-150 /// mount and 150 rides it. Unauthored (negative) data imposes no requirement rather than becoming @@ -526,82 +375,6 @@ mod tests { assert!(!mask_admits(u32::MAX, 33)); } - /// The gate is a PRE-SPEND sweep: it lives in `check_cast_gates`, whose whole contract is that - /// nothing in it writes and everything that spends runs strictly after it returns `Ok` (story 33). - /// Pinned by scan because the crate has no `ReducerContext` harness — what is asserted is that the - /// call sits inside that function, not merely somewhere in the cast core. - #[test] - fn the_mount_gate_runs_inside_the_pre_spend_cast_sweep() { - let gates = crate::test_scan::code_of( - include_str!("spell/cast/resolve.rs"), - "fn check_cast_gates(", - ); - assert!( - gates.contains("crate::mount::check_mount_cast(ctx, caster, effects, spell_id)?"), - "the mount gate must run inside `check_cast_gates` — anywhere later and a refused mount \ - has already charged power or started a cooldown. Body was:\n{gates}" - ); - } - - /// The gate reads the SHARED indoor predicate instead of testing MOGP flags itself, so T3's WMO - /// correctness fix lands in one place, and it reuses the same submerged predicate player breath - /// uses rather than inventing a second water-position interpretation. - #[test] - fn the_gate_reads_the_shared_indoor_and_liquid_predicates() { - let src = include_str!("mount.rs"); - let allowed = crate::test_scan::code_of(src, "pub(crate) fn mount_cast_allowed_here("); - assert!( - allowed.contains("crate::vmap::is_indoor(ctx, map_id, x, y, z)"), - "the indoor predicate must go through the shared WMO indoor query. Body was:\n{allowed}" - ); - let submerged = crate::test_scan::code_of(src, "fn is_submerged_here("); - assert!( - submerged.contains("lyracore_shared::env::is_submerged(") - && submerged.contains("crate::terrain::liquid_level_at("), - "the liquid gate must reuse the breath predicate, not re-derive one. Body was:\n{submerged}" - ); - let gate = crate::test_scan::code_of(src, "pub(crate) fn check_mount_cast("); - assert!( - gate.contains("mount_cast_allowed_here(ctx, caster.map_id") - && !gate.contains("mogp") - && !gate.contains("area_info"), - "the gate must ask the shared predicate rather than inline a WMO flag test or its own \ - area query. Body was:\n{gate}" - ); - } - - /// The gate CENSUS (stories 12, 16, 17, 18, 20) and its read-only property (story 33). All five - /// refusals must still be present, and `check_mount_cast` must not write: it runs inside the - /// pre-spend sweep, so a single `insert`/`update`/`delete` here would make a refused mount - /// non-atomic. Combat state comes from the module's own engagement fold, never a session flag, - /// and a released spirit is refused alongside a corpse. - #[test] - fn the_gate_refuses_five_ways_and_writes_nothing() { - let gate = - crate::test_scan::code_of(include_str!("mount.rs"), "pub(crate) fn check_mount_cast("); - for needle in [ - "requires riding training", - "you cannot mount while dead", - "you cannot mount while in combat", - "you cannot mount indoors", - "you cannot mount in deep water", - "crate::combat::is_engaged(ctx, caster.guid)", - "player_flags::GHOST", - ] { - assert!( - gate.contains(needle), - "`check_mount_cast` lost `{needle}`. Body was:\n{gate}" - ); - } - for write in [".insert(", ".update(", ".delete("] { - assert!( - !gate.contains(write), - "`check_mount_cast` must stay read-only — it runs before anything is spent, and a \ - `{write}` here makes a refused mount non-atomic. Body was:\n{gate}" - ); - } - } - /// The heartbeat's 100 ms window, at the boundary it actually decides. Two heartbeats inside one /// window skip the area query; the first heartbeat of a new window pays it. The clock is the /// CLIENT's own `move_time_ms`, so a client that stops sending simply stops being checked — and a @@ -616,367 +389,4 @@ mod tests { // A client clock that rolls backwards still reads as a crossing rather than wedging the check. assert!(indoor_check_is_due(5_000, 1_100)); } - - /// The movement heartbeat (story 21): the check is gated on the mount PROJECTION of the row - /// already in hand and the 100 ms window BEFORE any area query, so an unmounted heartbeat — the - /// overwhelming majority — pays one field compare. The dismount itself must run AFTER the entity - /// row is written: `dismount` recomputes the projection straight onto the stored row, and the - /// pre-dismount copy `apply_movement_update` holds would put the mount back if it were flushed - /// afterwards. Taxi passengers never reach either half (`movement_is_suppressed` returns first). - #[test] - fn the_movement_heartbeat_checks_indoors_for_mounted_players_only() { - let body = crate::test_scan::code_of( - include_str!("world.rs"), - "pub(crate) fn apply_movement_update(", - ); - assert!( - body.contains("mover.mount_display_id != 0") - && body.contains("crate::mount::indoor_check_is_due(old_move_ms, move_time_ms)"), - "the heartbeat indoor check must sit behind the mounted test AND the 100 ms client-clock \ - gate. Body was:\n{body}" - ); - let write = body - .find("entities.guid().update(mover);") - .expect("the heartbeat still writes the mover row"); - let dismount = body - .find("crate::mount::dismount(ctx, mover_guid);") - .expect("the heartbeat still converges on the shared dismount"); - assert!( - dismount > write, - "the heartbeat dismount must run AFTER the entity row write, or the stale in-hand copy \ - restores the mount display. Body was:\n{body}" - ); - let suppressed = body - .find("crate::taxi::movement_is_suppressed(ctx, mover.guid)") - .expect("the taxi suppression guard still opens the heartbeat"); - assert!( - suppressed < dismount, - "a taxi passenger must never reach the indoor check. Body was:\n{body}" - ); - } - - /// The teleport path (story 22): `teleport_player` writes position outside the heartbeat, so it - /// asks the SAME shared predicate and converges on the SAME dismount, after the position write for - /// the same clobber reason the heartbeat has. - #[test] - fn teleport_asks_the_same_indoor_predicate() { - let body = - crate::test_scan::code_of(include_str!("world.rs"), "pub(crate) fn teleport_player("); - assert!( - body.contains("crate::mount::mount_cast_allowed_here(ctx, map_id, x, y, z)") - && body.contains("crate::mount::dismount(ctx, player_guid);"), - "a teleport must run the shared indoor check at the DESTINATION and converge on the \ - shared dismount. Body was:\n{body}" - ); - } - - /// Accepted-action dismount, attack half (story 19): both attack-start cores drop the mount after - /// the last validation gate and BEFORE the `game_melee_attack` row is armed, so a packet that - /// fails target validation changes nothing at all. - #[test] - fn an_accepted_attack_start_dismounts_before_the_engagement_is_armed() { - let src = include_str!("combat/engage.rs"); - for (signature, gate) in [ - ( - "fn start_attack(", - "validate_attack_target(ctx, &attacker, target_guid)?;", - ), - ( - "pub(crate) fn apply_start_ranged_attack(", - "let target = validate_attack_target(ctx, &attacker, target_guid)?;", - ), - ] { - let body = crate::test_scan::code_of(src, signature); - let validated = body.find(gate).unwrap_or_else(|| { - panic!("`{signature}` no longer validates the target before arming:\n{body}") - }); - let dismount = body - .find("crate::mount::dismount(ctx, attacker.guid);") - .unwrap_or_else(|| { - panic!("`{signature}` must dismount an accepted attacker:\n{body}") - }); - let arm = body - .find("let melee = ctx.db.game_melee_attack();") - .unwrap_or_else(|| panic!("`{signature}` no longer arms the engagement:\n{body}")); - assert!( - validated < dismount && dismount < arm, - "`{signature}` must dismount AFTER target validation and BEFORE the engagement row, \ - or a rejected attack packet dismounts the player. Body was:\n{body}" - ); - } - } - - /// Accepted-action dismount, cast half (stories 19/23): an accepted cast drops the mount before its - /// effects run, and the MOUNT CAST ITSELF is exempt — keyed on the presence of an `A_MOUNTED` - /// effect, never a spell id, so onboarding another mount is still data alone. The hook sits after - /// `check_cast_gates` (a refused cast returned already) and after the cost charge, because that - /// charge flushes a caster row read before the hook and would otherwise overwrite the recompute. - #[test] - fn an_accepted_cast_dismounts_and_the_mount_cast_is_exempt() { - let body = crate::test_scan::code_of( - include_str!("spell/cast/resolve.rs"), - "pub(crate) fn resolve_cast_at_typed(", - ); - let gates = body - .find("check_cast_gates(ctx, &caster, &hdr, &effects, target_guid, spell_id, level)?;") - .expect("the cast core still runs the gate sweep"); - let charge = body - .find("ctx.db.game_world_entity().guid().update(caster);") - .expect("the cast core still flushes the charged caster row"); - let exempt = body - .find("if !effects.iter().any(|e| e.kind == A_MOUNTED) {") - .expect("the cast dismount must exempt a spell carrying an A_MOUNTED effect"); - assert!( - gates < exempt && charge < exempt, - "the cast dismount must run after the gate sweep AND after the caster-row charge. \ - Body was:\n{body}" - ); - let flat = body.split_whitespace().collect::>().join(" "); - assert!( - flat.contains( - "if !effects.iter().any(|e| e.kind == A_MOUNTED) { crate::mount::dismount(ctx, caster_guid); }" - ), - "the exemption must guard exactly the shared dismount call. Body was:\n{body}" - ); - } - - /// The item seam (story 2): a mount item is REUSABLE. `apply_item_use` consumes a stack unit for - /// every on-use spell except the kinds `spell_keeps_item` names, and a mount joins the Hearthstone - /// there through [`spell_is_mount`] — by effect kind, with no item-entry allowlist, so onboarding - /// another mount is data alone. Pinned by scan: both functions are `ReducerContext`-bound. - #[test] - fn a_mount_item_survives_its_own_use() { - let src = include_str!("items/ops.rs"); - let keeps = crate::test_scan::code_of(src, "fn spell_keeps_item("); - assert!( - keeps.contains("crate::mount::spell_is_mount(ctx, spell_id)"), - "`spell_keeps_item` must classify a mount through the one shared effect-kind test, or a \ - mount item is eaten like a potion. Body was:\n{keeps}" - ); - let use_item = crate::test_scan::code_of(src, "pub(crate) fn apply_item_use("); - assert!( - use_item.contains("if !spell_keeps_item(ctx, spell_id) {"), - "the stack consumption must stay behind `spell_keeps_item`. Body was:\n{use_item}" - ); - } - - /// The REBUILD census (stories 6 and 7). `build_player_entity` constructs every player row at - /// display 0 and the GM speed carry, but the mount aura outlives the despawn — a relog, a - /// WORLDPORT_ACK map change and a cross-database handoff all rebuild from it. So every caller of - /// that constructor must restore the projection AFTER inserting the row, or a rider comes back on - /// foot holding an active mount buff. A new rebuild path changes this list and forces the decision - /// to be made rather than silently regressing. - #[test] - fn every_player_rebuild_restores_the_mount_projection() { - // (file, why it rebuilds a player entity) - const REBUILDS: &[(&str, &str)] = &[ - ("module/src/debug/mod.rs", "debug_spawn_player_entity"), - ( - "module/src/world.rs", - "player_login, incl. the WORLDPORT_ACK rebuild", - ), - ]; - - let mut found: Vec = Vec::new(); - for path in crate::test_scan::module_sources() { - let source = crate::test_scan::read_scanned(&path).expect("module/ is never optional"); - let Some(build) = source - .match_indices("crate::build_player_entity(ctx,") - .find(|(idx, _)| { - !crate::test_scan::on_comment_line(&source, *idx) - && !crate::test_scan::in_string_literal(&source, *idx) - }) - .map(|(idx, _)| idx) - else { - continue; - }; - let insert = source[build..] - .find("entities.insert(entity);") - .map(|off| build + off) - .unwrap_or_else(|| panic!("{path} no longer inserts the built row")); - let restore = source[build..] - .find("crate::mount::restore_mount_on_rebuild(ctx,") - .map(|off| build + off) - .unwrap_or_else(|| { - panic!( - "{path} rebuilds a player entity without restoring the mount projection — \ - a mounted character relogs or crosses a shard boundary on foot" - ) - }); - assert!( - insert < restore, - "{path}: the restore must run AFTER the row is inserted — `recompute_mount` writes \ - the STORED row and silently no-ops on a row that is not there yet" - ); - found.push(path); - } - let expected: Vec = REBUILDS - .iter() - .map(|(file, _)| (*file).to_string()) - .collect(); - assert_eq!( - found, expected, - "the player-rebuild census changed. Every `build_player_entity` caller must call \ - `mount::restore_mount_on_rebuild` after inserting the row." - ); - } - - /// The restore is guarded on actually being mounted, and converges on the ONE recompute rather - /// than stamping the display itself. The guard is load-bearing: an unguarded recompute would - /// overwrite the `pending_run_speed_mult_bp` GM `.speed` carry that `build_player_entity` just - /// read, on every ordinary login. - #[test] - fn the_rebuild_restore_is_guarded_and_converges_on_the_recompute() { - let body = crate::test_scan::shape_of( - include_str!("mount.rs"), - "pub(crate) fn restore_mount_on_rebuild(", - ); - assert!( - body.contains("if active_mount_spell(ctx, guid).is_some()") - && body.contains("recompute_mount(ctx, guid);"), - "the rebuild restore must recompute ONLY for a player the aura set says is mounted, so an \ - ordinary login keeps its GM `.speed` carry. Body was:\n{body}" - ); - } - - /// The dismount TRIGGER census — the counterpart to the aura-deletion census below. Story 25 - /// ("damage does not dismount") is proven here rather than by a damage-path special case: the - /// damage paths do not appear, and cannot start appearing without this list changing. Every entry - /// is an ACCEPTED action or an authoritative position write, never an incoming event. - #[test] - fn every_dismount_trigger_is_accounted_for() { - // (file, calls, why it dismounts) - const TRIGGERS: &[(&str, usize, &str)] = &[ - ( - "module/src/combat/engage.rs", - 2, - "accepted melee and ranged attack start, before the engagement is armed", - ), - ( - "module/src/spell/cast/resolve.rs", - 1, - "accepted cast start; a spell with an A_MOUNTED effect is exempt", - ), - ( - "module/src/spell/cast/targeting.rs", - 2, - "mount replacement inside aura_apply, and the E_DISMOUNT effect arm", - ), - ( - "module/src/world.rs", - 2, - "the movement heartbeat's indoor edge, and the teleport destination check", - ), - ]; - - let mut found: Vec<(String, usize)> = Vec::new(); - for path in crate::test_scan::module_sources() { - let source = crate::test_scan::read_scanned(&path).expect("module/ is never optional"); - let calls = source - .match_indices("mount::dismount(") - .filter(|(idx, _)| { - !crate::test_scan::on_comment_line(&source, *idx) - && !crate::test_scan::in_string_literal(&source, *idx) - }) - .count(); - if calls > 0 { - found.push((path, calls)); - } - } - let expected: Vec<(String, usize)> = TRIGGERS - .iter() - .map(|(file, n, _)| ((*file).to_string(), *n)) - .collect(); - assert_eq!( - found, expected, - "the dismount trigger census changed. A mount comes off on an ACCEPTED action or an \ - authoritative position write — never on taking damage, which is DBC interrupt-flag \ - driven through `break_auras_on_damage` and needs no mount-specific code." - ); - } - - /// Removal convergence, the whole point of this module: the crate has no single aura-DELETION - /// boundary, so this pins the complete census of `game_aura` deletion sites. Each file below is - /// either wired to the mount recompute or cannot remove a mount aura at all, with the reason. - /// A new deletion path changes this count and forces that decision to be made explicitly rather - /// than silently leaving a stuck mount display behind. - #[test] - fn every_aura_deletion_site_is_accounted_for() { - // (file, deletions, why it is safe) - const CENSUS: &[(&str, usize, &str)] = &[ - ( - "module/src/mount.rs", - 1, - "dismount — the shared operation itself; recomputes", - ), - ( - "module/src/spell/cast/targeting.rs", - 1, - "displace_auras — called only from aura_apply, which recomputes after the insert", - ), - ( - "module/src/spell/control.rs", - 3, - "break_auras_on_damage (ordinary damage must NOT dismount — the mount's DBC \ - interrupt bit is underwater-cancel, not break-on-damage), break_channel \ - (A_PERIODIC_TRIGGER rows only), and shed_auras — the shared involuntary-removal \ - tail behind the death shed and a spent Proc's buff; wired", - ), - ( - "module/src/spell/effects.rs", - 2, - "dispel_target — wired; absorb_incoming — deletes the spent A_ABSORB row only", - ), - ( - "module/src/spell/math.rs", - 2, - "remove_seal_auras and break_stealth — both scoped to their own aura kind", - ), - ( - "module/src/spell/scheduler.rs", - 3, - "do_cancel_aura and the expiry reap — both wired; the channel-end pass deletes \ - A_PERIODIC_TRIGGER rows only", - ), - ( - "module/src/spell/spellbook.rs", - 1, - "strip_spell_auras — wired", - ), - ( - "module/src/spell/stacking.rs", - 1, - "apply_group_conflict — evicts only members of the incoming spell's own stacking \ - group; no group contains a mount spell", - ), - ( - "module/src/spell/tables.rs", - 1, - "sweep_delete_game_aura — the character-deletion sweep; the entity goes with it", - ), - ]; - - let mut found: Vec<(String, usize)> = Vec::new(); - for path in crate::test_scan::module_sources() { - let source = crate::test_scan::read_scanned(&path).expect("module/ is never optional"); - let deletes = crate::test_scan::raw_table_reads(&source, &["game_aura"], |c, idx| { - c[idx..].trim_start().starts_with(".id().delete(") - }) - .len(); - if deletes > 0 { - found.push((path, deletes)); - } - } - let expected: Vec<(String, usize)> = CENSUS - .iter() - .map(|(file, n, _)| ((*file).to_string(), *n)) - .collect(); - assert_eq!( - found, expected, - "the `game_aura` deletion census changed. Every deletion site must either call \ - `mount::recompute_mount` when a removed row satisfies `mount_aura_moves_mount`, or be \ - listed here with the reason it cannot remove a mount aura — otherwise a mount can be \ - deleted while `mount_display_id` keeps projecting it." - ); - } } diff --git a/module/src/realm_chat.rs b/module/src/realm_chat.rs index 53cb24326..aa7fb5718 100644 --- a/module/src/realm_chat.rs +++ b/module/src/realm_chat.rs @@ -328,7 +328,6 @@ fn refused_chat(refusal: ChatRefusal, speaker_guid: u64) -> String { #[cfg(test)] mod tests { use super::*; - use crate::test_scan::code_of; use lyracore_shared::chat::chat_tag; fn request(kind: u8, race: u8, language: u32, message: &str) -> RealmChatRequest { @@ -604,19 +603,4 @@ mod tests { ]) ); } - - /// The speaker's guid is an argument, so the operator gate is the entire authorization. A gate - /// that is present but neutralized (`if false`, `let _ =`, an early return above it) is no gate, - /// so the scan anchors to the opening brace. - #[test] - fn the_realm_chat_reducers_are_operator_gated() { - for signature in ["pub fn realm_chat(", "pub fn realm_whisper("] { - let body = code_of(include_str!("realm_chat.rs"), signature); - let normalized: String = body.split_whitespace().collect::>().join(" "); - assert!( - normalized.starts_with("{ crate::helpers::require_operator(ctx)?;"), - "`{signature}` no longer OPENS with the operator gate. Body was:\n{body}" - ); - } - } } diff --git a/module/src/region.rs b/module/src/region.rs index a07eda10c..1e79a07b4 100644 --- a/module/src/region.rs +++ b/module/src/region.rs @@ -260,35 +260,6 @@ mod tests { ); } - #[test] - fn the_reducer_routes_every_write_through_the_epoch_rule() { - // The call-site tripwire (the pattern `transfer.rs` uses for its fences): the pure rules - // above are worthless if the reducer stops asking them, and the reducer itself cannot run - // without a node — so deleting `assignment_write` from `set_region_assignment`, or writing - // the row without it, turns THIS test red instead of leaving the whole suite green. - let src = include_str!("region.rs"); - let body = src - .split_once("pub fn set_region_assignment") - .expect("the reducer is still named set_region_assignment") - .1; - let body = body.split_once("#[cfg(test)]").map_or(body, |(b, _)| b); - let code: String = body - .lines() - .filter(|l| !l.trim_start().starts_with("//")) - .collect::>() - .join("\n"); - assert!( - code.contains("assignment_write("), - "set_region_assignment must decide its write with `assignment_write` — the epoch rule \ - and the tombstone form are only enforced if the reducer actually calls it" - ); - assert!( - !code.contains("delete(&key)"), - "an un-assign must leave a TOMBSTONE, not delete the row: deleting it drops the epoch \ - high-water mark and a stale retry can then resurrect the superseded assignment" - ); - } - #[test] fn region_keys_are_unique_per_map_and_region() { // The packed key is the primary key: a collision would let one region silently overwrite diff --git a/module/src/seed.rs b/module/src/seed.rs index b56e98af6..d8949479b 100644 --- a/module/src/seed.rs +++ b/module/src/seed.rs @@ -1913,10 +1913,7 @@ pub(crate) const CREATEINFO_KIT: &[(u8, u8, u32)] = &[ // auto-migrate publish. Everything it seeds is therefore either only-if-empty or an upsert, and // is additionally reachable from a feature-gated `debug_seed_*` twin so a long-lived shard can be // brought forward without a re-provision. Neither property is checkable at runtime here (no -// `ReducerContext` harness exists by design), so the DATA -// invariants are asserted directly and the two structural ones are pinned by a source scan -// through `test_scan::code_of`, which strips comments (a bare `.contains()` on an unstripped body -// is exactly what a trailing-comment needle defeats). +// `ReducerContext` harness exists by design), so the DATA invariants are asserted directly. // =========================================================================================== #[cfg(test)] mod tests { @@ -1995,43 +1992,6 @@ mod tests { ); } - /// The starting-kit seeder remains only-if-empty: it must not rewrite an operator's kit edits. - /// The spell-group seeder is deliberately different: it converges existing development databases - /// on the reconciled starter rows while avoiding duplicate memberships. - #[test] - fn starter_kit_seeder_still_returns_early_before_writing_anything() { - let src = include_str!("seed.rs"); - let body = crate::test_scan::code_of( - src, - "pub(crate) fn seed_createinfo_spells(ctx: &ReducerContext) {", - ); - let guard_at = body - .find("if table.count() > 0 {") - .expect("starting-kit guard is gone"); - let return_at = body[guard_at..] - .find("return;") - .expect("starting-kit guard does not return"); - let insert_at = body - .find(".insert(") - .expect("starting-kit seeder does not insert"); - assert!( - guard_at + return_at < insert_at, - "starting-kit guard must precede its first write" - ); - } - - /// The seeder converges an already-migrated database instead of skipping it: rules are updated in - /// place and only absent memberships are inserted, so re-running it duplicates nothing. - #[test] - fn spell_group_seeder_updates_rules_and_never_duplicates_memberships() { - let body = crate::test_scan::code_of( - include_str!("seed.rs"), - "pub(crate) fn seed_spell_groups(ctx: &ReducerContext) {", - ); - assert!(body.contains("rules.group_id().update(rule_row)")); - assert!(body.contains(".any(|row| row.spell_id == spell_id)")); - } - /// Every rank of every Blessing the starter set claims, checked against the 1.12.1 client's /// `Spell.dbc` (see `docs/data-ingestion.md`). Ranks went missing here once already. #[test] @@ -2201,131 +2161,4 @@ mod tests { "fixture item entries collide: {items:?}" ); } - - /// REACHABILITY, which is idempotence's other half. `init` does not re-run on an auto-migrate - /// publish, so a fixture reachable ONLY from `init` never lands on an already-provisioned - /// shard, and one reachable only from a `debug_seed_*` reducer never lands on a fresh one - /// unless a harness happens to call it. Both halves have gone wrong here: the comments in - /// `init` record `seed_stealth_fixture` having been debug-only (the audit) and - /// `seed_fixture_catalogue` being moved into `init` for exactly this reason. - /// - /// So: every fixture seeder must be called from at least one of the two, and the failure names - /// which one is stranded. - #[test] - fn every_fixture_seeder_is_reachable_from_init_or_from_a_debug_reducer() { - let fixtures_src = include_str!("seed/fixtures.rs"); - let init_src = include_str!("seed.rs"); - let debug_src = crate::test_scan::debug_dir_src(); - - let seeders: Vec<&str> = fixtures_src - .lines() - .filter_map(|line| { - let rest = line.trim().strip_prefix("pub(crate) fn ")?; - let name = rest.split('(').next()?; - name.starts_with("seed_").then_some(name) - }) - .collect(); - - assert!( - seeders.len() >= 8, - "found only {} fixture seeders in seed/fixtures.rs — the extraction scan has stopped \ - matching and would pass vacuously. Did the declaration style change?", - seeders.len() - ); - - for name in seeders { - let call = format!("{name}(ctx)"); - let from_init = init_src.contains(&call); - let from_debug = debug_src.contains(&call); - assert!( - from_init || from_debug, - "`{name}` is never called: not from `init` (so it never lands on a fresh shard) \ - and not from a `debug_*` reducer (so it can never be applied to an existing one). \ - A fixture nobody seeds is a test that silently stops testing anything." - ); - } - } - - /// DRIFT REGRESSION. The post-import fixture-restore path - /// (`seed_scenario_fixtures`/`seed_fixture_items`, run via `debug_seed_scenario_fixtures` - /// after a world-ETL re-import truncates `game_creature_template`/`game_item_template`) used - /// to re-author full `CreatureTemplate`/`ItemTemplate` literals as hand-copies of `init`'s — - /// and they drifted: the Profession Trainer was level 30/1500hp/"Cooking & Skinning" in - /// `init` but level 10/100hp/"Fixture" in the restore copy, and the Test Wolf's - /// money_min/max disagreed too. A shard restored after an ETL wipe therefore carried - /// different fixtures than a fresh one — the exact cross-shard divergence class was - /// filed to kill, reintroduced by copy-paste. - /// - /// The fix collapses both paths onto ONE canonical constructor per fixture - /// (`test_wolf_template`, `profession_trainer_template`, `tempered_blade_template`, - /// `tough_jerky_template`, all in `seed/fixtures.rs`). This pins that both `init` (via - /// `seed_map0_demo_content`, the stratum-2 split-out — see this file's header) and the - /// restore path call the SAME constructors — a hand-copied struct literal reintroduced in - /// either one fails this test loudly instead of silently drifting again. - #[test] - fn init_and_the_restore_path_build_shared_fixtures_from_the_same_constructor() { - let seed_src = include_str!("seed.rs"); - let fixtures_src = include_str!("seed/fixtures.rs"); - - let init_body = crate::test_scan::code_of( - seed_src, - "fn seed_map0_demo_content(ctx: &ReducerContext) {", - ); - let restore_body = crate::test_scan::code_of( - fixtures_src, - "pub(crate) fn seed_scenario_fixtures(ctx: &ReducerContext) {", - ); - let fixture_items_body = crate::test_scan::code_of( - fixtures_src, - "fn seed_fixture_items(ctx: &ReducerContext) {", - ); - - for ctor in ["test_wolf_template()", "profession_trainer_template()"] { - assert!( - init_body.contains(ctor), - "`init` no longer calls `{ctor}` — did a hand-authored CreatureTemplate literal \ - come back?" - ); - assert!( - restore_body.contains(ctor), - "`seed_scenario_fixtures` (the post-import fixture-restore path) no longer calls \ - `{ctor}` — a hand-copied literal here is exactly the #363 drift bug." - ); - } - - for ctor in ["tempered_blade_template(", "tough_jerky_template("] { - assert!( - init_body.contains(ctor), - "`init` no longer calls `{ctor}` — did a hand-authored ItemTemplate literal come \ - back?" - ); - assert!( - fixture_items_body.contains(ctor), - "`seed_fixture_items` (feeds the restore path's reserved-id fixture catalogue) no \ - longer calls `{ctor}` — a hand-copied literal here is exactly the #363 drift bug." - ); - } - - // Belt-and-suspenders, scoped to the fixture the constructor replaced (this function also - // hand-authors OTHER, non-duplicated fixtures — Scenario Questgiver/Vendor/Weapon Master — - // which is fine; only a WOLF/PROFESSION_TRAINER-entry literal here would be the drift bug - // back). Whitespace-collapsed word-pair match, NOT `.contains()` — `target_entry: WOLF` - // (the quest objective, which is legitimate) would otherwise false-positive on a plain - // substring search for "entry: WOLF". - let restore_shape = crate::test_scan::shape_of( - fixtures_src, - "pub(crate) fn seed_scenario_fixtures(ctx: &ReducerContext) {", - ); - let words: Vec<&str> = restore_shape.split(' ').collect(); - for needle in [["entry:", "WOLF,"], ["entry:", "PROFESSION_TRAINER,"]] { - assert!( - !words.windows(2).any(|w| w == needle), - "`seed_scenario_fixtures` hand-authors a `CreatureTemplate {{ {} {} ... }}` \ - literal again instead of calling the shared constructor — this is how #363 \ - happened.", - needle[0], - needle[1] - ); - } - } } diff --git a/module/src/spell/control.rs b/module/src/spell/control.rs index 77985659d..3354cf308 100644 --- a/module/src/spell/control.rs +++ b/module/src/spell/control.rs @@ -878,74 +878,4 @@ mod tests { assert!(!aura_survives_death(0x4)); // an unrelated attribute never spares an aura assert!(!aura_survives_death(!SPELL_ATTR_PASSIVE)); } - - /// BOTH kill chokepoints shed — vanilla's removal sits on `Unit`, so a creature drops its auras on - /// death exactly like a player. In `kill_creature` the shed must run AFTER the reward step, which - /// still reads the victim's live aura set to find the killer's Drain Soul channel: shedding first - /// would silently stop soul shards dropping. Pinned by scan — both functions are - /// `ReducerContext`-bound and the crate has no reducer harness. - #[test] - fn both_kill_chokepoints_shed_auras_on_death() { - let src = include_str!("../combat/death.rs"); - for signature in [ - "pub(crate) fn kill_player(", - "fn kill_creature_with_attribution(", - ] { - let body = crate::test_scan::code_of(src, signature); - assert!( - body.contains("crate::spell::remove_auras_on_death(ctx, "), - "`{signature}` must shed the dying unit's auras — otherwise a corpse keeps its mount, \ - its buffs and the crowd control that was on it. Body was:\n{body}" - ); - } - let creature = crate::test_scan::code_of(src, "fn kill_creature_with_attribution("); - let rewards = creature - .find("award_tag_rewards(") - .expect("kill_creature still awards the Loot Tag before the corpse work"); - let shed = creature - .find("crate::spell::remove_auras_on_death(ctx, target_guid)") - .expect("checked above"); - assert!( - rewards < shed, - "the death shed must run AFTER `award_tag_rewards`, which reads the dying creature's \ - Drain Soul channel aura to mint an entitled source's soul shard. Body was:\n{creature}" - ); - } - - /// The shed's shape: it reads the exemption through the ONE policy predicate (never an inline - /// attribute test or a spell-id list), and hands its rows to the shared removal tail, which - /// converges the same three recomputes every other aura removal site runs — vitals, then sheet, - /// then the Mount Projection. Dropping any of them leaves a corpse projecting a stat pool, a - /// paperdoll number or a mount it no longer has. - #[test] - fn the_death_shed_reads_the_policy_and_converges_every_projection() { - let collect = crate::test_scan::code_of( - include_str!("control.rs"), - "pub(crate) fn remove_auras_on_death(", - ); - assert!( - collect.contains("aura_survives_death(s.attributes)"), - "the shed must ask the one policy predicate. Body was:\n{collect}" - ); - assert!( - collect.contains("shed_auras(ctx, unit_guid, shed)"), - "the shed must hand its collected rows to the shared removal tail. Body was:\n{collect}" - ); - let body = - crate::test_scan::code_of(include_str!("control.rs"), "pub(crate) fn shed_auras("); - let vitals = body - .find("recompute_vitals(ctx, unit_guid)") - .expect("the shed must re-derive the vitals pools a lost STA/INT aura fed"); - let sheet = body - .find("recompute_sheet(ctx, unit_guid)") - .expect("the shed must re-derive the character sheet"); - let mount = body - .find("crate::mount::recompute_mount(ctx, unit_guid)") - .expect("the shed must re-derive the Mount Projection, or a corpse keeps riding"); - assert!( - vitals < sheet && sheet < mount, - "vitals must recompute before the sheet, so a lost stat aura's sheet recompute sees the \ - already-updated spirit. Body was:\n{body}" - ); - } } diff --git a/module/src/spell/proc.rs b/module/src/spell/proc.rs index 1464a70fd..cefef61bb 100644 --- a/module/src/spell/proc.rs +++ b/module/src/spell/proc.rs @@ -1059,140 +1059,4 @@ mod tests { assert_eq!(proc_chance_bp(&frozen, ProcSide::Dealer, 2000), 333); assert_eq!(proc_chance_bp(&frozen, ProcSide::Victim, 2000), 1_500); } - - // ---- Architecture Tests ----------------------------------------------------------------- - - /// ONE proc pass, called from ONE place. `apply_hit` is the chokepoint every damaging hit already - /// routes through, so a new damage path gets procs by construction — a second call site anywhere - /// else is how that guarantee is lost. - #[test] - fn the_proc_pass_is_called_only_from_apply_hit() { - let mut offenders: Vec = Vec::new(); - let mut in_death = 0usize; - for rel in crate::test_scan::module_sources() { - // `proc.rs` defines the pass (and its own tests name it in prose); `combat/death.rs` is - // the one caller, whose calls are placed exactly below. - if rel.ends_with("spell/proc.rs") { - continue; - } - let src = crate::test_scan::read_scanned(&rel).expect("module/ is never optional"); - for (idx, _) in src.match_indices("run_proc_pass(") { - if crate::test_scan::on_comment_line(&src, idx) - || crate::test_scan::in_string_literal(&src, idx) - { - continue; - } - if rel.ends_with("combat/death.rs") { - in_death += 1; - } else { - offenders.push(format!("{rel}:{}", crate::test_scan::line_of(&src, idx))); - } - } - } - assert!( - offenders.is_empty(), - "`run_proc_pass` is called outside `combat::apply_hit` ({offenders:?}). The pass exists \ - once, at the one chokepoint every damaging hit routes through, so a new damage path \ - cannot forget procs." - ); - // Every call sits inside `apply_hit` itself — one per outcome branch, because each branch - // decides for itself whether the target survived: the duel finisher and the ordinary survivor - // fire both sides, the killing blow fires the attacker's Procs only. - let death = crate::test_scan::read_scanned("module/src/combat/death.rs") - .expect("module/ is never optional"); - let body = crate::test_scan::code_of(&death, "pub(crate) fn apply_hit("); - assert_eq!( - body.matches("run_proc_pass(").count(), - 3, - "`apply_hit` must run the proc pass once per outcome branch — duel-completed, killed and \ - survived. A branch that writes damage and skips the pass is a damage path that forgot \ - procs. Body was:\n{body}" - ); - assert_eq!( - in_death, 3, - "combat/death.rs calls `run_proc_pass` somewhere other than `apply_hit`" - ); - } - - /// A damage Proc's amount is damage like any other: it resists, then goes through - /// `apply_target_damage` — the shared pipeline's spell entrance, which reaches `apply_hit` and with - /// it the absorb, the lethal fork, the threat and the kill credit. A hand-written health write here - /// would be the fifth copy of a pipeline that already drifted twice, and it would rob the Carrier of - /// the kill its zap landed. - #[test] - fn the_damage_proc_routes_its_amount_through_the_shared_pipeline() { - let src = crate::test_scan::read_scanned("module/src/spell/proc.rs") - .expect("module/ is never optional"); - let body = crate::test_scan::code_of(&src, "fn fire_proc_damage("); - for needle in ["apply_resistance(", "apply_target_damage("] { - assert!( - body.contains(needle), - "the damage Proc arm no longer reaches `{needle}` — its amount must resist and then \ - land through the one shared damage pipeline. Body was:\n{body}" - ); - } - assert!( - body.contains("HitSource::Triggered"), - "the damage Proc arm must mark its hit Triggered, or a Lightning Shield zap starts a proc \ - pass of its own and two shields ping-pong. Body was:\n{body}" - ); - assert!( - !body.contains("game_world_entity()"), - "the damage Proc arm writes the target's health by hand again instead of routing through \ - `apply_target_damage`. Body was:\n{body}" - ); - } - - /// A Triggered Cast pays nothing and passes nothing: no Gate sweep, no cost charge, no global or - /// per-spell cooldown, no stealth break, no dismount. Losing any of those exclusions turns a proc - /// into something that can block or tax the Carrier's own casts. - #[test] - fn a_triggered_cast_pays_no_cost_and_passes_no_gates() { - let src = crate::test_scan::read_scanned("module/src/spell/cast/resolve.rs") - .expect("module/ is never optional"); - let body = crate::test_scan::code_of(&src, "pub(crate) fn cast_triggered("); - for forbidden in [ - "check_cast_gates", - "game_spell_cooldown", - "game_spell_cd", - "break_stealth", - "dismount", - "remove_items", - "fire_on_cast_resolved", - ] { - assert!( - !body.contains(forbidden), - "`cast_triggered` reached `{forbidden}` — a Triggered Cast must cost nothing, pass \ - no Gate and start no cooldown. Body was:\n{body}" - ); - } - } - - /// The debug damage reducer is the fast way to drive this pass: waiting on real swing timers turns - /// a 100-hit run into minutes. It must therefore route through the SHARED pipeline as a main-hand - /// hit, not write health by hand — a raw field write raises no combat event and fires no Proc. - #[test] - fn the_debug_damage_reducer_drives_the_pass_as_a_main_hand_hit() { - let body = crate::test_scan::code_of( - &crate::test_scan::debug_dir_src(), - "pub fn debug_apply_damage(", - ); - for needle in [ - "crate::combat::fold_incoming_damage(", - "crate::combat::apply_hit(", - "HitSource::MainHand", - ] { - assert!( - body.contains(needle), - "`debug_apply_damage` no longer reaches `{needle}` — it must drive the same \ - fold-then-apply pipeline a real swing does, or it stops driving the proc pass. Body \ - was:\n{body}" - ); - } - assert!( - !body.contains("entities.guid().update("), - "`debug_apply_damage` writes health by hand again instead of routing through the shared \ - pipeline. Body was:\n{body}" - ); - } } diff --git a/module/src/spell/scheduler.rs b/module/src/spell/scheduler.rs index 13d46c80c..87005ecbe 100644 --- a/module/src/spell/scheduler.rs +++ b/module/src/spell/scheduler.rs @@ -939,38 +939,6 @@ pub(crate) fn classify_ground_tick( } } -#[cfg(test)] -mod duel_periodic_wiring_tests { - #[test] - fn periodic_damage_rechecks_authorization_and_finishes_at_one_health() { - let body = crate::test_scan::code_of(include_str!("scheduler.rs"), "pub fn tick_auras("); - assert!(body.contains("crate::combat::may_harm")); - assert!(body.contains("crate::duel::active_duel_between")); - assert!(body.contains("pending.insert(a.target_guid, 1)")); - assert!(body.contains("crate::duel::complete_duel")); - } - - /// The periodic fold is the one damage resolver that cannot route through `final_damage`: it - /// folds several auras against an in-transaction health the durable row does not carry yet. It - /// must still consult the floor before marking a creature dying, or a DoT tick becomes the only - /// way to kill a creature a script is holding at one health. - #[test] - fn a_periodic_kill_consults_the_lethal_damage_floor_before_marking_a_creature_dying() { - let body = crate::test_scan::code_of(include_str!("scheduler.rs"), "pub fn tick_auras("); - let protects = body - .find("crate::combat::lethal_floor_protects(ctx, a.target_guid)") - .expect("the periodic creature-kill branch no longer consults the lethal floor"); - let dying = body - .find("dying.insert(a.target_guid, killer)") - .expect("the periodic creature-kill branch no longer marks a creature dying"); - assert!( - protects < dying, - "the floor must be consulted BEFORE the creature is marked dying" - ); - assert!(body.contains("crate::combat::commit_death_prevention")); - } -} - #[cfg(test)] mod ground_tick_tests { use super::*; diff --git a/module/src/spell/stacking.rs b/module/src/spell/stacking.rs index d1e5adf35..d2e5b5c49 100644 --- a/module/src/spell/stacking.rs +++ b/module/src/spell/stacking.rs @@ -964,8 +964,7 @@ mod tests { } /// Removal by ANY path — natural expiry, dispel, or break-on-damage. Mirrors the one line - /// `dr_window_on_removal` writes around its row read; the call sites themselves are pinned by - /// `every_removal_path_stamps_the_window_at_the_actual_removal_time`. + /// `dr_window_on_removal` writes around its row read. fn remove(&mut self, at: i64) { let row = self .row @@ -1045,61 +1044,6 @@ mod tests { assert_eq!(probe.row.expect("row persists").level, 1); } - /// Every removal path stamps the window from the reducer's OWN timestamp — never from the aura's - /// scheduled `expires_at`, which is what would keep an early removal's window running too long. - #[test] - fn every_removal_path_stamps_the_window_at_the_actual_removal_time() { - for (source, signature) in [ - ( - include_str!("scheduler.rs"), - "pub fn tick_auras(", // natural expiry - ), - ( - include_str!("effects.rs"), - "pub(crate) fn dispel_target(", // dispel - ), - ( - include_str!("control.rs"), - "pub(crate) fn break_auras_on_damage(", // break on damage - ), - ] { - let body = crate::test_scan::code_of(source, signature); - assert!( - body.contains("dr_window_on_removal("), - "{signature} must start the DR window when it removes an aura" - ); - assert!( - body.contains("ctx.timestamp.to_micros_since_unix_epoch()") - || body.contains("now.to_micros_since_unix_epoch()"), - "{signature} must stamp the window from the removal instant" - ); - assert!( - !body.contains("dr_window_on_removal(ctx, a.target_guid, category, expires"), - "{signature} must not stamp the window from the scheduled expiry" - ); - } - } - - /// The stored level advances only AFTER an aura row was actually placed or refreshed: a group or slot - /// refusal between the DR decision and the insert must leave the target's progression untouched. - #[test] - fn dr_level_advances_only_after_the_aura_is_placed() { - let aura_apply = crate::test_scan::code_of( - include_str!("cast/targeting.rs"), - "pub(crate) fn aura_apply(", - ); - let decision = aura_apply - .find("resolve_dr_for_target(") - .expect("aura_apply resolves DR"); - let insert = aura_apply - .find("auras.insert(Aura {") - .expect("aura_apply inserts the aura"); - let bump = aura_apply - .find("bump_dr_level(") - .expect("aura_apply advances DR"); - assert!(decision < insert && insert < bump); - } - /// Same double-poly scenario against a CREATURE target: `dr_category_for_effect` returns `None` for a /// non-player target (checked at the call site, before `resolve_dr_for_target` is ever invoked), so /// `aura_apply` never calls `resolve_dr` at all for a creature — modeled here as a `None` prior with no @@ -1178,144 +1122,6 @@ mod tests { assert!(compute_group_strength(true, 2, 1) > compute_group_strength(true, 1, 99_999)); } - /// Every `.rs` file under `module/src`, so the scans below cannot be sidestepped by putting a - /// second aura path outside the spell module. - fn module_sources() -> Vec { - fn walk(dir: &std::path::Path, out: &mut Vec) { - for entry in std::fs::read_dir(dir).expect("module source directory is readable") { - let path = entry.expect("module source entry is readable").path(); - if path.is_dir() { - walk(&path, out); - } else if path.extension().is_some_and(|ext| ext == "rs") { - out.push(path); - } - } - } - let mut out = Vec::new(); - walk(&crate::test_scan::repo_root().join("module/src"), &mut out); - out.sort(); - out - } - - /// `path` relative to the repo root, for a readable failure message. - fn rel(path: &std::path::Path) -> String { - path.strip_prefix(crate::test_scan::repo_root()) - .unwrap_or(path) - .display() - .to_string() - } - - /// Every live `game_aura` insert in `module/src`, as `file:line`. - fn aura_insert_sites() -> Vec { - module_sources() - .iter() - .flat_map(|path| { - let source = std::fs::read_to_string(path).expect("module source is readable"); - let file = rel(path); - crate::test_scan::raw_table_reads(&source, &["game_aura"], |content, idx| { - content[idx..].trim_start().starts_with(".insert(") - }) - .into_iter() - .map(move |(line, _)| format!("{file}:{line}")) - .collect::>() - }) - .collect() - } - - /// Live (non-comment, non-string-literal) calls to or definitions of `name` in `source`. - fn live_calls(source: &str, name: &str) -> usize { - let needle = format!("{name}("); - source - .match_indices(needle.as_str()) - .filter(|(idx, _)| { - !crate::test_scan::on_comment_line(source, *idx) - && !crate::test_scan::in_string_literal(source, *idx) - && crate::test_scan::is_standalone_ident(source, *idx, name) - }) - .count() - } - - /// The single-boundary guard. `raw_table_reads` follows both the inline `ctx.db.game_aura()` - /// call and this crate's dominant `let auras = ctx.db.game_aura();` handle idiom, and skips - /// comments and string literals — so this test's own needles never satisfy it. - #[test] - fn every_aura_entry_point_converges_on_the_authoritative_insertion_boundary() { - // `debug::debug_fill_aura_slots` writes synthetic filler rows to stage a full aura range, so it - // must skip the very boundary the capacity probe exercises. It is the ONLY exempt site. - let sites = aura_insert_sites(); - let gameplay: Vec<_> = sites - .iter() - .filter(|s| !s.starts_with("module/src/debug/mod.rs:")) - .collect(); - assert_eq!( - gameplay.len(), - 1, - "a second game_aura insertion path bypasses aura_apply: {sites:?}" - ); - assert!( - gameplay[0].starts_with("module/src/spell/cast/targeting.rs:"), - "the authoritative insertion site moved out of aura_apply: {sites:?}" - ); - assert!( - sites.len() <= 2, - "a second debug game_aura insertion path appeared: {sites:?}" - ); - - // The one insertion sits inside `aura_apply`, after the group, DR and slot decisions. - let targeting = include_str!("cast/targeting.rs"); - let aura_apply = crate::test_scan::code_of(targeting, "pub(crate) fn aura_apply("); - assert_eq!( - crate::test_scan::raw_table_reads(&aura_apply, &["game_aura"], |content, idx| { - content[idx..].trim_start().starts_with(".insert(") - }) - .len(), - 1, - "aura_apply must retain exactly one game_aura insertion site" - ); - assert!(aura_apply.contains("apply_group_conflict(")); - assert!(aura_apply.contains("resolve_dr_for_target(")); - assert!(aura_apply.contains("pick_aura_slot(")); - - // The three entry points: normal casts (`apply_effect`), linked debuffs - // (`apply_linked_debuff`) and passive/talent/racial grants (`apply_spell_auras`). - let resolve = include_str!("cast/resolve.rs"); - for (source, signature) in [ - (targeting, "pub(crate) fn apply_effect("), - (targeting, "pub(crate) fn apply_linked_debuff("), - (resolve, "pub(crate) fn apply_spell_auras("), - ] { - let body = crate::test_scan::code_of(source, signature); - assert!( - live_calls(&body, "aura_apply") >= 1, - "`{signature}` no longer reaches the aura-application boundary" - ); - } - // …and nothing else calls it. One definition plus exactly three call sites; a fourth entry - // point must be routed through one of them or justified by moving this pin. - let calls: usize = module_sources() - .iter() - .map(|path| { - let source = std::fs::read_to_string(path).expect("module source is readable"); - live_calls(&source, "aura_apply") - }) - .sum(); - assert_eq!( - calls, 4, - "aura_apply gained or lost a caller — every aura entry point must converge here" - ); - - // A multi-effect spell calls the boundary once per effect; its own earlier effect must not - // be treated as a conflicting family member of the later ones. - let conflict = crate::test_scan::code_of( - include_str!("stacking.rs"), - "pub(crate) fn apply_group_conflict(", - ); - assert!( - conflict.contains("a.spell_id != spell_id"), - "sibling effects of one spell must be excluded from their own family conflict" - ); - } - /// `group_rule_from_u8` decodes the four documented rule bytes; an unrecognized byte is the safe /// `Stacks` no-op fallback (never silently refuses/evicts on a data bug). #[test] diff --git a/module/src/spell/tests.rs b/module/src/spell/tests.rs index 74c278e31..10026b543 100644 --- a/module/src/spell/tests.rs +++ b/module/src/spell/tests.rs @@ -558,36 +558,6 @@ fn aura_kind_wire_values_exhaustive() { } } -/// Review round 2's actual finding: the (now-retired) `apply_spell_auras_rejects_instant_effects` and -/// `apply_spell_auras_still_applies_aura_effects` tests both called `passive_applies_effect_kind` -/// DIRECTLY — they pinned the predicate's logic but never touched `apply_spell_auras` itself. A -/// reviewer deleted the `.filter(|e| passive_applies_effect_kind(e.kind))` call from -/// `apply_spell_auras`'s effect query (the fix's actual production wiring) and the full test suite -/// stayed green, because nothing exercised the call site. This is a source-scan tripwire on that WIRING: -/// it catches the filter call being DELETED (the shape this defect took, confirmed by mutating it and -/// watching this test go red) or the query losing the `.filter(` step entirely. It does NOT catch the -/// filter being wired to a DIFFERENT, wrong predicate at the same call site (`.filter(|e| -/// some_other_fn(e.kind))` still reads as "filtered" to a scan) — the two kind-exhaustive tests above -/// are what pin the LOGIC; this one only pins that SOME call to `passive_applies_effect_kind` still -/// gates the query. Uses the crate-shared scan primitives (`crate::test_scan`) rather than a local -/// copy — found this file's own copy was the seventh, and it carried the weaker (non-string- -/// literal-aware) trailing-comment stripper the canonical one was hardened against. -#[test] -fn apply_spell_auras_still_calls_the_passive_effect_filter() { - let body = crate::test_scan::code_of( - include_str!("cast/resolve.rs"), - "pub(crate) fn apply_spell_auras(", - ); - let normalized: String = body.split_whitespace().collect::>().join(" "); - assert!( - normalized.contains(".filter(|e| passive_applies_effect_kind(e.kind))"), - "`apply_spell_auras` no longer filters its effect query through `passive_applies_effect_kind` — \ - without that call, every instant-kind effect of a talent/racial/resurrection-sickness passive is \ - handed straight to `aura_apply` again (#90: Consecration's spurious login/world-change buff). \ - Body was:\n{body}" - ); -} - /// Pin EVERY `P_*` param-tag value (`eff_p0_kind` — what `p0` MEANS on a given effect/aura row): the /// importer stamps these BY NAME and the readers (`resistance_bonus`, `is_immune_to_mechanic`, …) key off /// them, so a drift here would silently reinterpret a frozen `p0`. All 17 are distinct. diff --git a/module/src/stats.rs b/module/src/stats.rs index 97b7aef7c..78ee8e358 100644 --- a/module/src/stats.rs +++ b/module/src/stats.rs @@ -284,89 +284,6 @@ pub(crate) fn set_character_level( mod tests { use super::*; - /// A level-set must move the DING BAR, not just clear the banked xp. - /// - /// `set_character_level` is ctx glue (playbook §7), so this pins its shape: both rows must be - /// written, and `next_level_xp` must be recomputed alongside the `xp = 0` on each. Dropping - /// either write reinstates the live defect — a bot staged at level 10 kept level 1's threshold - /// and dinged to 11 on its first kill, which silently invalidated every bot test written around - /// "an L10 bot" (kit level, the GRIND ±3 band, rotation `min_level`). The xp half of this pair - /// was already fixed once (266); the bar was left where it was. - #[test] - fn setting_a_level_also_moves_the_ding_threshold() { - let body = crate::test_scan::code_of( - include_str!("stats.rs"), - "pub(crate) fn set_character_level(", - ); - for (row, needle) in [ - ( - "entity", - "e.next_level_xp = crate::xp::xp_to_next_level(level)", - ), - ( - "character", - "c.next_level_xp = crate::xp::xp_to_next_level(level)", - ), - ] { - assert!( - body.contains(needle), - "`set_character_level` no longer recomputes the {row} row's ding threshold, so a \ - levelled character dings on its next kill. Body was:\n{body}" - ); - } - // The threshold it writes must actually GROW with the level — a constant would satisfy the - // scan above while leaving the bar at level 1's value. - assert!(crate::xp::xp_to_next_level(10) > crate::xp::xp_to_next_level(1)); - } - - /// The level-derived stat block (attributes, armor, max health/power) used to be - /// hand-mirrored at three call sites — `build_player_entity` (login), `grant_xp`'s ding loop, and - /// `set_character_level` — kept in sync only by a comment ("mirrors build_player_entity"). The - /// third one drifted and dropped the armor recompute, so a level-SET character kept stale armor - /// until its next relog. The fix extracts ONE `apply_level_stats` writer; this pins that (a) the - /// writer still recomputes armor, and (b) all three sites still route through it rather than - /// re-inlining the curve lookups — a fresh hand-rolled copy at any of them reintroduces the exact - /// drift class was filed for. - #[test] - fn the_level_recompute_writer_and_all_three_call_sites_stay_wired_together() { - let writer_body = - crate::test_scan::code_of(include_str!("stats.rs"), "pub(crate) fn apply_level_stats("); - assert!( - writer_body.contains("e.armor = agility * 2"), - "`apply_level_stats` no longer recomputes armor — every caller relies on this ONE writer \ - for it, so dropping it here breaks all three sites at once, silently. Body was:\n{writer_body}" - ); - - let set_level_body = crate::test_scan::code_of( - include_str!("stats.rs"), - "pub(crate) fn set_character_level(", - ); - assert!( - set_level_body.contains("apply_level_stats(ctx, &mut e, race, class, level)"), - "`set_character_level` must route the stat recompute through `apply_level_stats` rather \ - than re-inlining the curve lookups — a hand-rolled copy is exactly how #362 dropped \ - armor the first time. Body was:\n{set_level_body}" - ); - - let login_body = crate::test_scan::code_of( - include_str!("creatures/spawn.rs"), - "pub fn build_player_entity(", - ); - assert!( - login_body.contains( - "apply_level_stats(ctx, &mut entity, character.race, character.class, level)" - ), - "`build_player_entity` (login) must route through `apply_level_stats` too — this is the \ - site the other two are supposed to mirror. Body was:\n{login_body}" - ); - - let ding_body = crate::test_scan::code_of(include_str!("xp.rs"), "pub(crate) fn grant_xp("); - assert!( - ding_body.contains("apply_level_stats(ctx, p, race, class, p.level)"), - "`grant_xp`'s ding loop must route through `apply_level_stats` too. Body was:\n{ding_body}" - ); - } - #[test] fn stamina_curve_matches_vanilla_breakpoints() { assert_eq!(hp_from_stamina(0), 0); diff --git a/module/src/taxi.rs b/module/src/taxi.rs index 9709c7aa7..e50340729 100644 --- a/module/src/taxi.rs +++ b/module/src/taxi.rs @@ -1486,38 +1486,6 @@ mod tests { }; assert!(reply_belongs_to_character(&row, 7)); assert!(!reply_belongs_to_character(&row, 8)); - - let ack = crate::test_scan::code_of(include_str!("taxi.rs"), "pub fn gw_ack_taxi_reply("); - assert!(ack.contains("require_operator(ctx)")); - assert!(ack.contains(".find(request_id)")); - assert!(ack.contains("reply_belongs_to_character(reply, character_guid)")); - assert!(ack.contains(".delete(request_id)")); - } - - #[test] - fn status_never_discovers_and_open_discovers_before_building_availability() { - let source = include_str!("taxi.rs"); - let status = crate::test_scan::code_of(source, "pub fn gw_taxi_node_status("); - assert!( - !status.contains("discover("), - "a status query must remain a persisted-state read" - ); - - let open = crate::test_scan::code_of(source, "pub fn gw_open_taxi("); - let discovers = open.find("discover(").expect("open discovers its source"); - let builds = open - .find("available_client_nodes(") - .expect("open builds route availability"); - assert!( - discovers < builds, - "the source must become known before availability is constructed" - ); - - let resolver = crate::test_scan::code_of(source, "fn resolve_flight_master("); - assert!( - resolver.contains("acting_entity_by_guid"), - "the gateway actor must resolve through the in-transit fence" - ); } #[test] diff --git a/module/src/trade.rs b/module/src/trade.rs index 92b8bc961..47dd24843 100644 --- a/module/src/trade.rs +++ b/module/src/trade.rs @@ -998,25 +998,6 @@ mod tests { assert!(mutation_clears_accepts(true, true)); } - /// The accept-reset rule's impure half, pinned by source scan (the `economy.rs` money-line - /// precedent — no `ReducerContext` harness can execute the mutators): all THREE offer - /// mutators route through `reset_accepts`, plus the commit's stale-window refusal. Deleting - /// any one call site fails here by name. - #[test] - fn every_offer_mutator_and_the_commit_refusal_route_through_reset_accepts() { - let src = crate::test_scan::read_scanned("module/src/trade.rs") - .expect("module/src/trade.rs ships in every checkout"); - // concat! so this test's own needle never counts itself (the build-scan-strip lesson). - let needle = concat!("let session = reset_accepts", "(ctx, session);"); - let calls = src.matches(needle).count(); - assert_eq!( - calls, 4, - "set item, clear item, and set gold must EACH clear the accept flags on a real \ - change (the anti-scam floor), and the commit's stale-window refusal must reset \ - both seats; found {calls} reset_accepts call sites" - ); - } - /// The reap policy: a session is stale only STRICTLY past the invite TTL since its /// last action — `touch_session` bumps `created_at` on every offer mutation and on window /// open, so this measures idleness, not total age. Boundary pinned: exactly-at-TTL survives. @@ -1065,22 +1046,6 @@ mod tests { ); } - /// Trade and mail are the same snapshot move. Keeping the receiver insert in the item module - /// prevents their bind, stack-cap, template, and bag-search rules from drifting again. - #[test] - fn trade_delivers_through_the_shared_snapshot_move_path() { - let src = crate::test_scan::read_scanned("module/src/trade.rs") - .expect("module/src/trade.rs ships in every checkout"); - assert!( - src.contains("crate::items::store_instance_state("), - "trade must recreate received items through the same snapshot path as mail" - ); - assert!( - !src.contains(concat!("fn deliver_traded_", "item(")), - "the open-coded trade receiver insert must be removed" - ); - } - /// Every refusal [`initiate_verdict`] owes the initiator, its wire kind, and the pinned check /// order: a gate refusal (dead/absent/range) outranks WrongFaction, which outranks Busy — so /// a dead cross-faction player in a trade hears YouDead, not Busy. diff --git a/module/src/trainer.rs b/module/src/trainer.rs index 8ffae0168..7b2de70ad 100644 --- a/module/src/trainer.rs +++ b/module/src/trainer.rs @@ -802,31 +802,6 @@ mod tests { ); } - /// The riding fork's two decisions, pinned on the buy path because the crate has no - /// `ReducerContext` harness: a riding offering is refused on a trainer that is not a - /// `trainer_type::MOUNTS` trainer, and a successful buy grants through `learn_riding` — never - /// `learn_profession`, whose born-at-1 row would leave a paying rider below every mount's - /// `min_skill`. - #[test] - fn the_riding_fork_requires_a_mounts_trainer_and_grants_the_whole_tier() { - let buy = crate::test_scan::code_of( - include_str!("trainer.rs"), - "pub(crate) fn apply_trainer_buy(", - ); - assert!( - buy.contains("crate::skill::is_riding_skill_line(profession_line)"), - "the riding offering must take its own fork. Body was:\n{buy}" - ); - assert!( - buy.contains("t.trainer_type != lyracore_shared::trainer::trainer_type::MOUNTS"), - "a riding offering must be refused on a non-MOUNTS trainer. Body was:\n{buy}" - ); - assert!( - buy.contains("crate::skill::learn_riding(ctx, caster_guid, owner, tier as u16)"), - "the riding grant must go through `learn_riding`, not the profession grant. Body was:\n{buy}" - ); - } - /// THE IDEMPOTENT RE-LEARN (professions slice 3): the profession buy keys its already-known gate on the /// PRESENCE of a `game_player_skill` row for the line (the `known` flag the buy path derives), and feeds /// it through the SAME `trainer_buy_check`. So a re-buy of an already-learned profession is rejected with diff --git a/module/src/transfer/harness.rs b/module/src/transfer/harness.rs index 7cf757b96..3b8583e33 100644 --- a/module/src/transfer/harness.rs +++ b/module/src/transfer/harness.rs @@ -36,28 +36,20 @@ //! * **What is executed here**: the transport plumbing every arm flows through (the codec, the //! guid it is handed, the registry lookup, the export/import loops, `not_transported`), plus every //! `apply_*` step body. -//! * **What a source scan still covers, and why**: +//! * **What the generated registry covers**: //! - each REAL table's arm EXISTS (`every_manifest_table_can_cross_a_database_boundary`) — read //! off build.rs's generated `CHARACTER_OWNED_TRANSFER_NAMES`, not off source text; //! - each arm transports rather than declining //! (`the_not_transported_allowlist_matches_the_arms_that_decline`) — likewise generated. //! moved this from a 100-line brace-depth parser to the `character_owned!` marker KIND, so //! "does this arm actually move rows" is a parse-time property now, not a scan's guess; -//! - the cross-database eviction keeps the instance LEASE -//! (`the_cross_database_eviction_keeps_the_instance_lease`) — one call inside a reducer whose -//! only observable effect is real table state, and extracting a sink for it would be more -//! scaffolding than the line it protects. A deliberate, written decision. //! * **The seam's own blind spot** — `CtxShard`, the thin production layer this harness substitutes //! `FakeDb` for. Nothing here runs any of its methods, and each is a single line whose damage is //! total: no-op'ing `CtxShard::import_rows` means **no manifest table's rows ever arrive**, and an //! early `return Ok(())` in a reducer shim means the reducer the gateway calls does nothing at all -//! while every test below still passes. It stays pinned by EXACT-SHAPE equality -//! (`tests::the_production_adapter_is_the_pass_through_the_harness_assumes`), and the -//! cargo-mutants run is what proved that pin still has to exist: a mutation tool can only ask -//! whether a test FAILS, and 54 mutants across `CtxShard` were MISSED because no headless test can -//! execute a `ReducerContext` at all. What DID retire is the pins over `begin_transfer`'s and -//! `reap_transfers`' 120-line bodies — those bodies are `apply_begin`/`apply_reap` now, and this -//! file runs them. +//! while every test below still passes. Nothing headless covers that layer; a durable test +//! against a real database is the only check. `begin_transfer`'s and `reap_transfers`' +//! 120-line bodies are `apply_begin`/`apply_reap` now, and this file runs them. //! * **What is still not covered anywhere headless**: SpacetimeDB's transaction rollback. A real //! `Err` from `import_character_blob` unwinds every write it made; [`FakeDb`] keeps them. Every //! refusal test below therefore asserts on the **in-row** — the row whose absence is what diff --git a/module/src/transfer/mod.rs b/module/src/transfer/mod.rs index 4e3d2333e..d912c5089 100644 --- a/module/src/transfer/mod.rs +++ b/module/src/transfer/mod.rs @@ -7,7 +7,7 @@ //! |---|---| //! | `mod.rs` (here) | the ledger tables, the pure decision core, the SINKS every step is written against, and the reducers that bind them to a `ReducerContext` | //! | `transport.rs` | the row transport: `RowIo`/`move_rows`/the bsatn codec, the manifest, and `ExportBlob` — everything that answers "what crosses, and in what shape" | -//! | `tests.rs` | the crash matrix, the pure-planner enumerations, and the source-scan tripwires | +//! | `tests.rs` | the crash matrix, the pure-planner enumerations, and the transport ratchets | //! | `harness.rs` | `FakeDb` — two in-memory "databases" the real protocol bodies are EXECUTED against | //! //! # The protocol @@ -66,7 +66,7 @@ //! * **REGENERATE at the destination** — `auth::establish_session`, deliberately unfenced. //! `Character.owner_identity` is per-CONNECTION derived state; a carried copy would arrive stale //! and be overwritten immediately, and a field that is always wrong on arrival is worse than no -//! field. Pinned by `owner_identity_is_regenerated_at_the_destination_never_carried`. +//! field. //! * **NOT A TRANSFER CONCERN** — the group verbs, settled: party membership is //! authoritative on realm-core, so there is no source-copy write left to lose. What replaced the //! fence question is a REPLICATION one, answered by putting `game_group_member` on @@ -79,10 +79,8 @@ //! verdict; nothing in it is stale, it is simply not something the protocol's own source has to //! restate. //! -//! Enforcement: each fenced call site is pinned by a named source-scan tripwire in `tests.rs` -//! (deleting a fence turns a NAMED test red — a pure model has no reducers in it and sees none of -//! them), and `tripwires.rs`'s `character_fence_tripwire` is the ratchet that stops a NEW unfenced -//! by-guid path from being added. +//! Enforcement: `tripwires.rs`'s `character_fence_tripwire` is the ratchet that stops a NEW +//! unfenced by-guid path from being added. //! //! # The CROSS-DATABASE protocol //! @@ -1079,9 +1077,7 @@ pub(crate) trait ReapSink: FinishSink { /// in the crate — and cargo-mutants cannot help either, because it can only ask whether a test /// FAILS and no test can execute a `ReducerContext` at all (measured: 54 missed mutants across this /// struct on the first full run, which is why `.cargo/mutants.toml` excludes it BY NAME with that -/// number written down). So it stays pinned by exact-shape equality, in -/// `tests::the_production_adapter_is_the_pass_through_the_harness_assumes` — the one string pin -/// Kept. Every method below must remain a single expression; if this stops being a layer of +/// number written down). Every method below must remain a single expression; if this stops being a layer of /// pass-throughs, the harness underneath it stops meaning what it claims. struct CtxShard<'a> { ctx: &'a ReducerContext, diff --git a/module/src/transfer/tests.rs b/module/src/transfer/tests.rs index d4f1faa13..64c29fa02 100644 --- a/module/src/transfer/tests.rs +++ b/module/src/transfer/tests.rs @@ -1,5 +1,4 @@ -//! The crash matrix, the pure-planner enumerations, and the source-scan tripwires that pin the -//! fences a pure model cannot see. +//! The crash matrix, the pure-planner enumerations, and the transport ratchets. //! //! Split out of `transfer.rs`; `harness.rs` holds the half that EXECUTES the protocol //! against two in-memory databases. @@ -257,43 +256,6 @@ fn aura_absolute_expiry_survives_the_export_import_round_trip_with_the_same_rema ); } -/// The transfer arm must RE-MINT `id` rather than keep it — `Aura.id` is `#[auto_inc]`, a -/// surrogate key local to ONE database, so carrying the source's id verbatim either collides -/// with an unrelated row the destination already minted under that same id, or (same-value -/// coincidence aside) simply means nothing there. -/// -/// Since that is one word in the arm's declaration (`remint = id` vs `keep_key`) rather -/// than a hand-written `row.id = 0` the macro cannot see — so this scan asserts the DECLARED -/// choice. `keep_key` is right for exactly one table in the tree (`game_item_instance`, whose -/// guid is derived from the owner and is the id the CLIENT knows the item by); everything else -/// re-mints, and picking the wrong one here is a live PK collision at the destination. -#[test] -fn aura_transfer_arm_remints_the_auto_inc_id_before_insert() { - let body = shape_of( - include_str!("../spell/tables.rs"), - "fn sweep_transfer_game_aura(", - ); - assert!( - body.contains("remint = id"), - "sweep_transfer_game_aura no longer re-mints `id` for the arriving row — an imported \ - aura would either collide with an existing auto_inc id on the destination or silently \ - masquerade as one that already means something there. Declaration was:\n{body}" - ); -} - -#[test] -fn taxi_discovery_transfer_arm_remints_the_auto_inc_id_before_insert() { - let body = shape_of( - include_str!("../taxi.rs"), - "fn sweep_transfer_game_character_taxi_node(", - ); - assert!( - body.contains("remint = id"), - "taxi discovery surrogate ids are local to one database and must be re-minted on arrival. \ - Declaration was:\n{body}" - ); -} - #[test] fn export_blob_round_trips_through_bsatn() { let blob = ExportBlob { @@ -956,325 +918,6 @@ fn a_character_round_trips_between_two_partitions() { } } -// ------------------------------------------------------------------------------------- -// ENFORCEMENT tripwire: the fence is three lines of code in three files, and the pure model -// cannot see any of them -// ------------------------------------------------------------------------------------- - -/// Everything above tests the DECISION surface. AC#4 ("no reducer can act on an in-transit -/// character") is not a decision, it is three call sites — and deleting any of them left the -/// whole 428-test suite green, because a pure model has no reducers in it. So source-scan them, -/// the `character_owned_tripwire` pattern in `tripwires.rs`: a tripwire is the only thing that can -/// catch a chokepoint that stopped being called. -/// -/// Isolate the enclosing fn body before matching, so the assertion is "THIS fn calls the gate", -/// not "the file mentions it somewhere in a doc comment". -/// -/// `body_of`/`code_of` (comment-stripped) are the shared scan primitives in -/// [`crate::test_scan`] (this used to be six near-identical copies across this file -/// and five others, and they had already drifted: the first cut of -/// `every_refuse_verdict_call_site_still_routes_through_the_by_guid_chokepoint` matched -/// comments that EXPLAIN each fence rather than the fence itself, and a trailing (as opposed to -/// whole-line) comment defeated the weak four-file version entirely). -use crate::test_scan::{body_of, code_of, shape_of}; - -/// This used to be the ONLY guard on `entity_by_owner`'s in-transit fence, and it is -/// a source scan — it can prove the right identifiers appear, but not that they mean the right -/// thing. The SENSE is now pinned directly (no `ReducerContext` needed) by -/// `helpers::tests::gate_in_transit_refuses_an_in_transit_candidate_and_returns_a_normal_one`; -/// swap `gate_in_transit`'s two branches and that test fails by name, something this scan could -/// never do. This test still earns its keep: it is what catches the fence being deleted, or its -/// call to the gate being routed around, outright. -/// -/// Collapsed all three chokepoints onto `helpers::gate_by_guid`, so the exact-shape STRING -/// PIN that used to sit next to this test (a whitespace-collapsed verbatim copy of -/// `entity_by_owner`'s body, which every legitimate edit had to update twice) is gone: there is -/// one two-line expression left where a `!` could hide, and it is inside `.cargo/mutants.toml`'s -/// cargo-mutants scope — where `replace gate_by_guid -> None` is CAUGHT. -#[test] -fn the_actor_chokepoint_still_calls_the_in_transit_gate() { - let body = code_of(include_str!("../helpers.rs"), "pub fn entity_by_owner("); - assert!( - body.contains("gate_by_guid("), - "helpers::entity_by_owner no longer routes its result through gate_by_guid. That call \ - is the ONE gate covering all 60+ player-fired reducers (issue #16, AC#4); without it \ - every one of them can act on a character that is mid-transfer. Body was:\n{body}" - ); -} - -/// ...and the gate they all share still consults the ledger and still routes the answer through -/// the pure decision. Splitting these two apart — computing `in_transit` and never passing it -/// on, or passing a constant — is the one edit the three call-site scans above cannot see, now -/// that they only prove `gate_by_guid` is CALLED. -#[test] -fn the_shared_gate_consults_the_ledger_and_defers_to_the_pure_decision() { - let body = code_of(include_str!("../helpers.rs"), "fn gate_by_guid("); - assert!( - body.contains("crate::transfer::is_in_transit(ctx, guid_of(row))"), - "helpers::gate_by_guid no longer asks the transfer ledger about the candidate's guid — \ - every in-transit fence in the tree is then open, and each of the three chokepoints \ - still LOOKS fenced. Body was:\n{body}" - ); - assert!( - body.contains("gate_in_transit(candidate, in_transit)"), - "helpers::gate_by_guid no longer hands its answer to `gate_in_transit`, the pure \ - decision that `gate_in_transit_refuses_an_in_transit_candidate_and_returns_a_normal_one` \ - pins. Body was:\n{body}" - ); -} - -#[test] -fn player_login_still_refuses_an_in_transit_character() { - // Stage 4d: the fence lives in the shared login core — both the sender reducer and - // gw_player_login delegate there, so pinning the core covers both entries. - let body = body_of( - include_str!("../world.rs"), - "pub(crate) fn apply_player_login(", - ); - assert!( - body.contains("is_in_transit"), - "world::player_login no longer fences in-transit characters. Login is the one path that \ - can re-materialise a live entity on a shard the character has LEFT — that is the \ - dual-liveness dupe the escrow exists to prevent. Body was:\n{body}" - ); -} - -#[test] -fn import_character_still_refuses_when_no_destination_copy_materialises() { - // The model says `ImportPlan::Apply` ⇒ the destination copy is durable. The reducer must - // MAKE that true: if it files the in-row while the apply silently did nothing, the in-row - // licenses `finish_transfer` (and the reaper's roll-forward) to clear the escrow, settling - // the transfer with ZERO durable copies. Model-vs-reality is exactly the gap a pure test - // cannot see. - let body = body_of(include_str!("mod.rs"), "pub fn import_character("); - assert!( - body.contains("no durable row at the destination"), - "import_character no longer refuses when the character row is absent — it can now file \ - an in-row for a destination copy that does not exist. Body was:\n{body}" - ); -} - -// ------------------------------------------------------------------------------------- -// ENFORCEMENT tripwires for the BY-GUID chokepoint, one NAMED test per fenced -// path — the review's lesson: deleting a fence must turn a test red, and a pure model -// sees no reducers, so each of these is a source scan of the call site's own body. -// ------------------------------------------------------------------------------------- - -/// The gate itself. Same shared decision as the actor chokepoint — all three route through -/// `helpers::gate_by_guid` and thence `gate_in_transit`, so -/// `gate_in_transit_refuses_an_in_transit_candidate_and_returns_a_normal_one` pins the SENSE of -/// all three at once, and -/// `the_shared_gate_consults_the_ledger_and_defers_to_the_pure_decision` pins the one wrapper. -/// This scan is what catches the fence being deleted, or routed around, outright. -#[test] -fn the_by_guid_chokepoint_still_calls_the_in_transit_gate() { - for signature in ["pub fn character_by_guid(", "pub fn character_by_name("] { - let body = code_of(include_str!("../helpers.rs"), signature); - assert!( - body.contains("gate_by_guid("), - "helpers::{signature} no longer routes its result through gate_by_guid. That call \ - is the gate for every reducer that reaches a character by guid or by name \ - (issue #30); without it the REFUSE verdicts in this module's table are all open. \ - Body was:\n{body}" - ); - } -} - -/// Every REFUSE-verdict call site, one assertion each: `(file, fn signature, what breaks)`. -/// Deleting the gate call from any one of them fails THIS test by name. -/// -/// A sign inversion does NOT need re-checking at each of these ~12 sites: none of -/// them touches `is_in_transit` — they call `character_by_guid`/`character_by_name`, and the -/// SENSE of those two now lives in exactly one place (`helpers::gate_in_transit`), pinned once -/// by `the_by_guid_chokepoint_is_exactly_the_pinned_shape`. A sign flip anywhere in the fence -/// itself fails that ONE test regardless of which of these call sites reads it; this test only -/// needs to prove each site still calls the (now sense-safe) wrapper at all. -#[test] -fn every_refuse_verdict_call_site_still_routes_through_the_by_guid_chokepoint() { - // Split `debug.rs` into the `debug/` directory; every `"debug.rs"` site below now - // scans this concatenation-of-the-whole-directory blob instead of one `include_str!`. - let debug_src = crate::test_scan::debug_dir_src(); - let sites: &[(&str, &str, &str, &str)] = &[ - ( - "auth.rs", - include_str!("../auth.rs"), - "pub fn delete_character(", - "delete_character destroys a durable copy another shard holds a claim on — \ - cross-database that is the annihilation case (the destination's arrival copy \ - survives and its reaper never settles)", - ), - ( - "gm.rs", - include_str!("../gm.rs"), - "pub fn set_gm_level(", - "set_gm_level writes gm_level onto the source copy by NAME", - ), - ( - "debug/mod.rs", - debug_src.as_str(), - "pub fn debug_spawn_player_entity(", - "debug_spawn_player_entity is player_login's RE-MATERIALISATION path wearing a \ - harness hat — unfenced it is a second route to the dual-liveness dupe", - ), - ( - "debug/mod.rs", - debug_src.as_str(), - "pub fn debug_set_money(", - "debug_set_money writes Character.money on the source copy", - ), - ( - "debug/mod.rs", - debug_src.as_str(), - "pub fn debug_expire_quest(", - "debug_expire_quest writes game_character_quest, a MANIFEST table", - ), - ( - "debug/mod.rs", - debug_src.as_str(), - "pub fn debug_grant_reputation(", - "debug_grant_reputation writes game_player_reputation, a MANIFEST table", - ), - ( - "debug/instance.rs", - debug_src.as_str(), - "pub fn debug_grant_default_actions(", - "debug_grant_default_actions writes game_player_action, a HOT manifest table", - ), - ( - "skill.rs", - include_str!("../skill.rs"), - "pub fn debug_reseed_skills(", - "debug_reseed_skills writes game_player_skill, a HOT manifest table", - ), - // --- Added by the review's independent call-site audit. --- - ( - "world.rs", - include_str!("../world.rs"), - "pub fn debug_delete_character(", - "debug_delete_character is auth::delete_character's gate-free harness twin, and \ - STRICTLY worse: cascade_delete_character runs the character_owned! sweep, which \ - includes sweep_delete_game_transfer_out, so an unfenced call destroys the \ - character AND both escrow rows in one transaction — cross-database the \ - destination's arrival copy is left with no source out-row and `recovery` answers \ - Hold forever", - ), - ( - "world.rs", - include_str!("../world.rs"), - "pub(crate) fn recall_to_home(", - "recall_to_home is the ONE teleport_player caller that needs no live entity (it \ - reads the home coords off the durable row), so by-guid (debug_use_hearthstone \ - and the Instance Removal expiry) it is the only route by which a home \ - durable-row write lands on an escrowed character, moving FIVE ExportBlob \ - fields plus the pending_instance_id that in_transit_instances reads", - ), - ( - "debug/mod.rs", - debug_src.as_str(), - "pub fn debug_set_level(", - "debug_set_level drives stats::set_character_level, which writes Character.level \ - (an ExportBlob field) and Character.xp on the DURABLE row and needs no live \ - entity to do it", - ), - ]; - for (file, src, signature, why) in sites { - let body = code_of(src, signature); - // `require_character` is `character_by_guid(..).ok_or_else(..)` folded - // into one helper — the fence is the SAME `character_by_guid` call, just no longer - // spelled out at the caller, so it counts as routing through the chokepoint too. - assert!( - body.contains("character_by_guid") - || body.contains("character_by_name") - || body.contains("require_character"), - "{file}'s `{signature}` no longer routes through helpers::character_by_guid / \ - character_by_name / require_character — the REFUSE fence is gone (issue #30). \ - {why}. Body was:\n{body}" - ); - } -} - -/// The BACKGROUND writers, found by the review's independent audit. Both are `game_tick_pass!` -/// bodies, so the argument that fences every other caller in their files — "the guid came from a -/// live entity, and `begin_transfer` deleted it" — does not reach them: neither reads -/// `game_world_entity` at all. They carry their own `is_in_transit` gate instead of routing -/// through `character_by_guid`, because both already hold the row (or its owned row) from a scan. -/// -/// Both refusals are DEFERRALS in substance, which is why REFUSE is honest here rather than -/// value-dropping: the rest pass leaves `rested_since_micros` running so the next pass banks the -/// whole span, and the quest pass leaves `deadline_micros` set so the next pass fails the quest. -#[test] -fn the_background_tick_passes_skip_in_transit_characters() { - let sites: &[(&str, &str, &str, &str)] = &[ - ( - "rest.rs", - include_str!("../rest.rs"), - "fn rested_accrue_pass(", - "the 30s rested-accrual pass filters the DURABLE row's `resting` flag and never \ - looks at a live entity. `begin_transfer` persists with `set_offline: false`, and \ - `persist_entity`'s `set_offline` branch is the one that calls \ - `materialize_on_logout` to stop the rest clock — so a character escrowed in an inn \ - keeps `resting == true` with a running clock and this pass rewrites `rested_xp` on \ - the frozen row every 30s, for as long as the escrow is held", - ), - ( - "quest.rs", - include_str!("../quest.rs"), - "fn quest_timer_pass(", - "the 0.5s timed-quest expiry pass writes `game_character_quest`, a MANIFEST table — \ - the same table and the same reasoning that fenced its harness twin \ - `debug::debug_expire_quest`, which this pass reached past", - ), - ]; - for (file, src, signature, why) in sites { - let body = code_of(src, signature); - assert!( - body.contains("is_in_transit"), - "{file}'s `{signature}` no longer skips in-transit characters (issue #30). {why}. \ - Body was:\n{body}" - ); - } -} - -/// The instance reaper's REFUSE verdict has a different shape (it refuses to read an instance -/// as EMPTY, rather than refusing to find a character), so it gets its own tripwire. -#[test] -fn the_instance_reaper_still_holds_instances_claimed_by_an_in_transit_character() { - let body = code_of(include_str!("../instance.rs"), "fn occupied_instances("); - assert!( - body.contains("in_transit_instances"), - "instance::occupied_instances no longer counts instances claimed by an in-transit \ - character. Occupancy is read from LIVE entities and begin_transfer deletes the live \ - entity, so without this the instance a transfer is flying into or out of reads as \ - empty, gets torn down, and takes its game_instance_binding manifest rows with it \ - (issue #30). Body was:\n{body}" - ); -} - -/// The DEFER verdict — the shape test for the whole taxonomy. A REFUSAL here would silently -/// drop a third party's copper, so the fence must be a fold into the blob, not a rejection. -#[test] -fn credit_purse_defers_the_copper_into_the_blob_instead_of_dropping_it() { - let body = code_of(include_str!("../loot/mod.rs"), "fn credit_purse("); - assert!( - body.contains("defer_money_delta"), - "loot::credit_purse no longer defers a post-begin coin share into the escrowed export \ - blob (issue #30). This is the ONE audited by-guid path a refusal gets wrong: the \ - recipient is a party member collecting their share of someone else's kill, so \ - dropping the write shorts a third party who is not transferring and cannot know why. \ - Body was:\n{body}" - ); - // ...and the fold must run BEFORE the durable-row write it accompanies, so an early return - // added above it can never skip the deferral while still paying the row. - let fold_at = body.find("defer_money_delta").expect("asserted above"); - let write_at = body.find("chars.guid().update(c)").expect( - "credit_purse no longer writes the durable character row — re-derive this ordering", - ); - assert!( - fold_at < write_at, - "the blob fold must precede the durable-row write. Body:\n{body}" - ); -} - /// The pure half of the DEFER verdict, driven directly: value in, value out, nothing dropped. #[test] fn folding_a_money_delta_adds_it_to_the_escrowed_blob() { @@ -1327,48 +970,6 @@ fn folding_a_money_delta_adds_it_to_the_escrowed_blob() { assert!(fold_money_delta(b"not a blob", 1).is_err()); } -/// The REGENERATE verdict, pinned by its REASON rather than by a fence (there is none to -/// delete). `Character.owner_identity` is per-CONNECTION derived state: if the blob ever grows -/// a field for it, it ships a source-gateway identity that is meaningless at the destination -/// and overwritten on arrival — a field always wrong on arrival is worse than no field. -#[test] -fn owner_identity_is_regenerated_at_the_destination_never_carried() { - let blob = code_of(include_str!("transport.rs"), "pub struct ExportBlob"); - assert!( - !blob.contains("owner_identity"), - "ExportBlob grew an owner_identity field. That value is rebound from the LIVE \ - connection by `establish_session` at every logon and restamped onto the owned rows by \ - `player_login`, so a carried copy arrives stale and is immediately overwritten — the \ - REGENERATE verdict in this file's table (issue #30). Blob was:\n{blob}" - ); - // Stage 4d: restamp lives in the shared login core (owner = ctx.sender() on the sender - // path, the account's bound identity on the gateway path — same regenerate semantics). - let login = code_of( - include_str!("../world.rs"), - "pub(crate) fn apply_player_login(", - ); - assert!( - login.contains("restamp_owned_data"), - "world::player_login no longer restamps the owned rows from ctx.sender(), which is the \ - mechanism the REGENERATE verdict for `establish_session` depends on: without it, a \ - character arriving at a destination shard keeps a stale owner binding and its rows go \ - RLS-invisible to the player. Body was:\n{login}" - ); -} - -#[test] -fn the_in_transit_predicate_is_the_login_fence_itself() { - // `is_in_transit` must stay defined AS `!login_allowed(..)`: the exhaustive walk's - // dual-liveness assertion is an assertion about `login_allowed`, and it only transfers to - // the real gates because they share this one predicate. - let body = body_of(include_str!("mod.rs"), "pub(crate) fn is_in_transit("); - assert!( - body.contains("!login_allowed(has_out, has_in)"), - "is_in_transit stopped being `!login_allowed(..)`, so the crash matrix's dual-liveness \ - proof no longer covers the real chokepoints. Body was:\n{body}" - ); -} - // ------------------------------------------------------------------------------------- // CROSS-DATABASE: the transport ratchet, and the six-step crash matrix // ------------------------------------------------------------------------------------- @@ -1509,12 +1110,6 @@ fn every_transported_table_has_exactly_one_arm() { ); } -/// The transport arms are the only thing that moves rows — but `move_rows` is what makes them -/// move anything, and NOTHING in this crate executes it (every module test is pure or a source -/// scan; the arms need a `ReducerContext`). Verified by mutation: making the `Import` arm an -/// unconditional early return — so that NO manifest table's rows ever arrive, on any transfer — -/// left all 468 module tests green. Until there is a harness that can run a reducer natively, -/// the two halves are pinned here. /// REAL behavioural coverage of the row codec — the half of the transport that actually carries /// player data, and which had none: every module test is pure or a source scan, so nothing in /// this crate ever executed `move_rows`. Mutation-testing confirmed the gap (making the import @@ -1571,44 +1166,6 @@ fn the_row_codec_round_trips_and_refuses_garbage() { ); } -#[test] -fn move_rows_delegates_to_the_codec_in_both_directions() { - let body = code_of( - include_str!("transport.rs"), - "pub(crate) fn move_rows(", - ); - assert!( - body.contains("encode_rows(export())"), - "move_rows' Export arm no longer serializes the exported rows — every table would ship \ - an empty payload and the character arrives naked. Body was:\n{body}" - ); - assert!( - body.contains("decode_rows::(bytes, outcome)") && body.contains("insert(ctx, r)"), - "move_rows' Import arm no longer decodes and INSERTS the arriving rows. That one arm is \ - the whole of cross-database row delivery: with it inert every character arrives with \ - no gear, spells, skills, talents, reputations or quest log, and finish_transfer \ - destroys the source copy they came from a moment later. Body was:\n{body}" - ); - // Neither arm may short-circuit: an early return anywhere in this shim drops a whole - // table's rows silently, and the codec's own empty-payload case is handled in `decode_rows`. - assert!( - !body.contains("return"), - "move_rows grew an early return. It is a two-line shim over the codec precisely so it \ - cannot acquire a path that skips a table. Body was:\n{body}" - ); - // Export must serialize the rows the CHARACTER owns — a mover handed the wrong guid ships - // an empty payload for every table, with no error anywhere. - let exp = code_of( - include_str!("transport.rs"), - "pub(crate) fn export_rows_via(", - ); - assert!( - exp.contains("mover(ctx, character_guid,"), - "export_rows no longer passes the transferring character's guid to each mover — every \ - table would export the rows of some other guid (or none). Body was:\n{exp}" - ); -} - /// The cross-database world: FOUR ledger facts instead of two, because neither database can /// read the other's. `in_row_src` is the ATTESTATION (`confirm_import`); `in_row_dst` is the /// arrival copy's own fence, cleared by `release_transfer`. @@ -1895,321 +1452,6 @@ fn the_arrival_copy_stays_fenced_until_the_source_copy_is_gone() { assert!(released.live_dst() && released.settled(), "{released:?}"); } -// --- ENFORCEMENT tripwires for the cross-database machinery (a pure model sees no reducers) --- - -// `finish_destroys_the_source_copy_for_a_cross_database_transfer` and its ordering assertions -// used to live here as a source scan of `fn do_finish(`. `do_finish` is now a two-line adapter -// over `apply_finish` (the seam), and every property that scan asserted — the cascade, -// its `cross_database` gate, detach-before-cascade, cascade-before-record_shard — is executed -// for real against `harness::FakeDb`. See `mod harness`'s `finish_*` tests. - -#[test] -fn import_character_blob_still_proves_the_destination_copy_is_durable() { - // The reducer is now a two-line adapter over `apply_import_blob` (the seam), so the - // guards below live in that body — but the ADAPTER still has to call it, and still has to - // gate on the operator. Both, in order, or the whole harness is testing dead code. - let shim = code_of(include_str!("mod.rs"), "pub fn import_character_blob("); - let gate_at = shim.find("require_operator(ctx)?").expect( - "import_character_blob dropped its operator gate — materialising a character from a \ - client-supplied blob is the whole of shard-hop forgery", - ); - let call_at = shim.find("apply_import_blob(").unwrap_or_else(|| { - panic!( - "import_character_blob no longer calls `apply_import_blob` — every guard the \ - harness executes is then unreachable from the reducer the gateway actually calls. \ - Body was:\n{shim}" - ) - }); - assert!( - gate_at < call_at, - "the operator gate must precede the import. Body:\n{shim}" - ); - - let body = code_of( - include_str!("mod.rs"), - "pub(crate) fn apply_import_blob(", - ); - assert!( - body.contains("no durable row at the destination"), - "import_character_blob no longer re-reads `game_character` to prove the arrival copy \ - materialised before filing the in-row. That in-row is what licenses `confirm_import` \ - and then `finish_transfer` to CASCADE-DELETE the source copy, so filing it against an \ - apply that silently did nothing settles the transfer with zero durable copies. Body \ - was:\n{body}" - ); - assert!( - body.contains("already has a LIVE entity on this shard"), - "import_character_blob no longer refuses a character that is already LIVE here — \ - importing on top of a resident character is the dual-liveness dupe with extra steps \ - (and cross-database the source cannot see it). Body was:\n{body}" - ); - // The MANIFEST-DRIFT guard — the contract, which the blob path inherited and which no - // test pinned (verified by mutation: deleting it left all 468 module tests green). Without - // it a shard on a different build accepts a payload for a table set it does not have, and - // silently drops whatever it cannot place. - assert!( - body.contains("check_manifest(transfer_id, &decoded.manifest)?"), - "import_character_blob no longer compares the arriving manifest against THIS build's. \ - A shard running a different character-owned table set would accept the import and \ - silently drop the tables it does not know — with the source copy deleted moments \ - later. Body was:\n{body}" - ); - // The payload must be applied FALLIBLY: `import_rows` refuses an unknown table or an - // undecodable one, and swallowing that error commits a PARTIAL character and then files the - // in-row that licenses destroying the whole source copy. - assert!( - body.contains("sink.import_rows(guid, &payload)?"), - "import_character_blob no longer propagates `import_rows`' error. A partial import is \ - the one outcome worse than none: the in-row filed immediately below it licenses \ - finish_transfer to cascade-delete the source copy the missing rows came from. Body \ - was:\n{body}" - ); - // the `defer_money_delta` residual: the blob's money is the escrowed value PLUS every - // delta folded in after the freeze, and this assignment is the only thing that replays them - // at the destination. Dropping it left all 468 module tests green. - assert!( - body.contains("c.money = decoded.money"), - "import_character_blob no longer applies `blob.money`, so a `credit_purse` folded into \ - the escrow by `defer_money_delta` after the freeze (issue #30's DEFER verdict) dies \ - with the source copy. Body was:\n{body}" - ); -} - -/// THE SEAM'S OWN BLIND SPOT — `CtxShard` and the reducer shims, pinned by EXACT SHAPE. -/// -/// `harness` executes every `apply_*` body, the codec and both registry loops. What it can -/// never execute is the thin layer that binds them to a real `ReducerContext`: [`CtxShard`]'s -/// one-line methods and the `#[reducer]` shims over them. The harness substitutes `FakeDb` for -/// every one of them, so any edit there is invisible to it — and each is a single line whose -/// damage is total. Verified by mutation, all green before this test existed: -/// -/// | edit | live consequence | -/// |---|---| -/// | `CtxShard::import_rows` → `Ok(())` | **no manifest table's rows ever arrive** | -/// | `CtxShard::export_rows` → `vec![]` | every table exports an EMPTY payload | -/// | `CtxShard::freeze_live_entity` → no-op | the character stays LIVE on the shard it left | -/// | `CtxShard::arm_reaper` → no-op | recovery is never scheduled; an abandoned transfer is a permanently frozen player | -/// | `CtxShard::escrows` → `vec![]` | the reaper walks nothing, same outcome | -/// | `CtxShard::record_shard` → no-op | the source keeps no forwarding receipt | -/// | `CtxShard::ensure_shadow_account` → no-op | the arriving player cannot log in at all | -/// | `CtxShard::has_live_entity` → `false` | the dual-liveness dupe guard is dead | -/// | `CtxShard::bump_guid_high_water` → no-op | this database can re-issue an imported character's guid | -/// | `CtxShard::own_guid_range` → `None` | the gate always reads "foreign"; a local arrival stops ratcheting | -/// | a `dest_y`/`dest_z` transposition in `begin_transfer`'s shim | the character arrives at the wrong place, and NOTHING else can see it | -/// | `return Ok(())` above any shim's `apply_*` call | the reducer the gateway calls does nothing while every test passes | -/// -/// **Why this pin survived the mutation sweep when the others did not.** The plan was to replace -/// every exact-shape pin with the cargo-mutants gate (`.cargo/mutants.toml`), and for the pins over -/// `begin_transfer`'s and `reap_transfers`' 120-line BODIES that is exactly what happened — -/// those bodies are `apply_begin`/`apply_reap` now and the harness runs them, so the stringified -/// twin that had to be edited alongside every legitimate change is gone. But cargo-mutants -/// cannot rescue THIS layer, and that is a measurement, not a guess: the first full run reported -/// 54 MISSED mutants across `CtxShard` and 6 more across the shims, because a mutation tool can -/// only ask whether a test FAILS, and no headless test in this crate can execute a -/// `ReducerContext` at all. So the instrument here is still equality — not `contains`, because a -/// `contains` scan is defeated by leaving its own text in a dead branch. -/// -/// The cost is real and is the right one: this layer is *supposed* to be twenty-odd -/// pass-throughs, it should almost never change, and if it stops being that then the harness -/// below it stops meaning what it claims. Re-bless a deliberate change here with the same care. -#[test] -fn the_production_adapter_is_the_pass_through_the_harness_assumes() { - let src = include_str!("mod.rs"); - for (signature, want) in [ - ( - "impl ShardLedger for CtxShard<'_> {", - "{ fn out_row(&self, transfer_id: u64) -> Option { self.ctx .db \ - .game_transfer_out() .transfer_id() .find(transfer_id) } fn in_row(&self, transfer_id: u64) \ - -> Option { self.ctx .db .game_transfer_in() .transfer_id() .find(transfer_id) } \ - fn file_out_row(&mut self, row: TransferOut) { self.ctx.db.game_transfer_out().insert(row); \ - } fn file_in_row(&mut self, row: TransferIn) { self.ctx.db.game_transfer_in().insert(row); } \ - fn delete_out_row(&mut self, transfer_id: u64) { self.ctx .db .game_transfer_out() \ - .transfer_id() .delete(transfer_id); } fn delete_in_row(&mut self, transfer_id: u64) { \ - self.ctx .db .game_transfer_in() .transfer_id() .delete(transfer_id); } fn \ - has_character(&self, guid: u64) -> bool { \ - self.ctx.db.game_character().guid().find(guid).is_some() } fn now_micros(&self) -> i64 { \ - self.ctx.timestamp.to_micros_since_unix_epoch() } }", - ), - ( - "impl BeginSink for CtxShard<'_> {", - "{ fn has_active_taxi_flight(&self, guid: u64) -> bool { \ - crate::taxi::is_in_flight(self.ctx, guid) } fn \ - is_in_transit(&self, guid: u64) -> bool { is_in_transit(self.ctx, guid) } fn \ - freeze_live_entity(&mut self, guid: u64) { let entities = self.ctx.db.game_world_entity(); \ - if let Some(e) = entities.guid().find(guid) { crate::world::persist_entity(self.ctx, &e, \ - false); entities.guid().delete(guid); } } fn export_rows(&self, guid: u64) -> Vec \ - { export_rows(self.ctx, guid) } fn with_character( &self, guid: u64, f: impl \ - FnOnce(&crate::character::Character) -> T, ) -> Option { self.ctx .db .game_character() \ - .guid() .find(guid) .map(|c| f(&c)) } fn arm_reaper(&mut self) { let sched = \ - self.ctx.db.game_transfer_reaper_schedule(); if sched.iter().next().is_none() { \ - sched.insert(TransferReaperSchedule { scheduled_id: 0, scheduled_at: \ - ScheduleAt::Interval(TimeDuration::from_micros( TRANSFER_REAP_INTERVAL_MICROS as i64, )), \ - }); } } }", - ), - ( - "impl ImportSink for CtxShard<'_> {", - "{ fn has_live_entity(&self, guid: u64) -> bool { \ - self.ctx.db.game_world_entity().guid().find(guid).is_some() } fn \ - detach_for_transfer(&mut self, guid: u64) { crate::group::detach_for_transfer(self.ctx, \ - guid); crate::bridge::detach_command_receipts_for_transfer(self.ctx, guid); } fn \ - cascade_delete_character(&mut self, guid: u64, listing_holds: ListingHolds) { \ - cascade_for_transfer(self.ctx, guid, listing_holds); } fn insert_character(&mut self, c: \ - crate::character::Character) { self.ctx.db.game_character().insert(c); } fn import_rows(&mut \ - self, guid: u64, payload: &[TableRows]) -> Result<(), String> { import_rows(self.ctx, guid, \ - payload) } fn ensure_shadow_account(&mut self, account_id: u64) { \ - crate::auth::ensure_shadow_account(self.ctx, account_id); } fn bump_guid_high_water(&mut \ - self, guid: u64) { crate::auth::bump_guid_high_water(self.ctx, guid); } fn \ - own_guid_range(&self) -> Option<(u64, u64)> { self.ctx .db .game_guid_range() .id() .find(0) \ - .map(|r| (r.base, r.size)) } }", - ), - ( - "impl FinishSink for CtxShard<'_> {", - "{ fn detach_for_transfer(&mut self, guid: u64) { crate::group::detach_for_transfer(self.ctx, \ - guid); crate::bridge::detach_command_receipts_for_transfer(self.ctx, guid); } fn \ - cascade_delete_character(&mut self, guid: u64, listing_holds: ListingHolds) { \ - cascade_for_transfer(self.ctx, guid, listing_holds); } fn record_shard(&mut self, guid: \ - u64, map_id: u32, instance_id: u64) { crate::realm_core::record_shard(self.ctx, guid, \ - map_id, instance_id); } }", - ), - ( - "impl ReapSink for CtxShard<'_> {", - "{ fn escrows(&self) -> Vec<(u64, u64, i64, bool)> { self.ctx .db .game_transfer_out() \ - .iter() .map(|o| { ( o.transfer_id, o.character_guid, o.created_micros, o.cross_database, ) \ - }) .collect() } }", - ), - ( - // Taxi refusal precedes ephemeral-session teardown; after that, trade and Duel - // teardown still precede the escrow write that flips the in-transit fence. - "pub fn begin_transfer(", - "{ require_operator(ctx)?; let character_guid = crate::account_ownership::require_actor(ctx, request_actor)?; if crate::taxi::is_in_flight(ctx, character_guid) { return \ - Err(\"PLAYER_IN_TAXI_FLIGHT\".to_string()); } \ - crate::trade::cancel_trade_for(ctx, character_guid); \ - crate::duel::interrupt_duel_for(ctx, character_guid); \ - apply_begin( &mut CtxShard { ctx }, transfer_id, character_guid, \ - Destination { map_id: dest_map_id, instance_id: dest_instance_id, x: dest_x, y: dest_y, z: \ - dest_z, o: dest_o, }, cross_database, ) }", - ), - ( - "pub fn import_character_blob(", - "{ require_operator(ctx)?; require_transfer_actor(ctx, transfer_id, request_actor)?; let character_guid = decode_blob(transfer_id, &blob)?.character_guid; crate::account_ownership::require_actor_for(ctx, request_actor, character_guid)?; apply_import_blob(&mut CtxShard { ctx }, transfer_id, blob) }", - ), - ( - "pub fn confirm_import(", - "{ require_operator(ctx)?; require_transfer_actor(ctx, transfer_id, request_actor)?; apply_confirm(&mut CtxShard { ctx }, transfer_id) }", - ), - ( - "pub fn release_transfer(", - "{ require_operator(ctx)?; require_transfer_actor(ctx, transfer_id, request_actor)?; if ctx .db .game_transfer_in() .transfer_id() .find(transfer_id) .is_some_and(|arrival| arrival.bot_intent_id != 0 || arrival.source_locator_revision != 0) { return Err(format!( \"transfer {transfer_id}: identified arrival requires its exact release reducer\" )); } apply_release(&mut CtxShard { ctx }, transfer_id) }", - ), - ( - "pub fn finish_transfer(", - "{ require_operator(ctx)?; require_transfer_actor(ctx, transfer_id, request_actor)?; apply_finish_step(&mut CtxShard { ctx }, transfer_id) }", - ), - ( - "pub fn reap_transfers(", - "{ if ctx.sender() != ctx.database_identity() { return; } apply_reap(&mut CtxShard { ctx }); \ - }", - ), - ] { - let want = want.split_whitespace().collect::>().join(" "); - assert_eq!( - shape_of(src, signature), - want, - "`{signature}` is no longer the exact pass-through `mod harness` assumes it is. The \ - harness runs the SHARED body underneath this layer with a `FakeDb` substituted for \ - every line here, and cargo-mutants cannot reach it either (no headless test can \ - execute a ReducerContext) — so nothing but this assertion covers the edit. A \ - no-op'd method or a short-circuited shim leaves the whole suite green while the \ - transport silently loses rows, or does nothing at all. If the change is \ - deliberate, update the expected shape here with the same care." - ); - } -} - -#[test] -fn the_transport_arms_are_the_only_thing_that_moves_rows() { - // `export_rows` must derive from the GENERATED registry, never a hand-kept list — the same - // property `manifest()` has, for the same reason: a parallel list drifts silently and the - // drift is invisible until a character arrives missing a table. - let body = code_of(include_str!("transport.rs"), "pub(crate) fn export_rows("); - assert!( - body.contains("CHARACTER_OWNED_TRANSFERS"), - "export_rows no longer iterates the generated transport registry. Body was:\n{body}" - ); - // And an arriving table this build has no arm for must ABORT, never be skipped: a skipped - // table is a table whose rows are dropped, and the source copy is about to be deleted. - let body = code_of( - include_str!("transport.rs"), - "pub(crate) fn import_rows_via(", - ); - assert!( - body.contains("refusing a partial import"), - "import_rows no longer refuses an unknown table — it now silently drops its rows, and \ - `finish_transfer` will destroy the source copy they came from. Body was:\n{body}" - ); - // AC 2 — the refusal must be LOUD, naming the tables in the module log and not - // only in the returned error. Nothing in this crate can capture SpacetimeDB's `log::error!`, - // so this is a source scan: the behavioural half (refuse, name them, file no in-row) is - // covered for real by `a_payload_missing_a_manifest_table_is_refused_and_files_no_in_row`, - // and gutting the log line alone was a live mutation survivor of that test. - // - // COUNTING the occurrences is not enough — that was itself a mutation survivor: strip the - // names out of the format arguments and park the second occurrence in a dead - // `let _dead = missing.join(", ");`, and the count is still two while the operator gets a - // refusal with no diagnosis. So pin the join as the log macro's own ARGUMENT. - let norm = body.split_whitespace().collect::>().join(" "); - assert!( - norm.contains("missing.len(), missing.join(\", \") );"), - "the missing-table refusal no longer LOGS the table names as `log::error!` arguments \ - (they must reach both the module log and the returned error) — an operator watching a \ - shard reject an import would see a refusal with no diagnosis. Body was:\n{body}" - ); - assert!( - body.contains("log::error!") && body.matches("missing.join(\", \")").count() == 2, - "the missing-table refusal no longer names the tables in BOTH the module log and the \ - returned error. Body was:\n{body}" - ); -} - -// ------------------------------------------------------------------------------------- -// SCAN-PINNED, and why (AC 5) -// -// Both guards below are a single call inside a reducer body, and their effect is only -// observable in real table state — `teardown_instance_inner`'s row deletion and -// `ensure_group`'s insert both need a `ReducerContext`, which no unit test can construct. -// Extracting a sink trait for each (the seam `apply_import_blob` uses) would be more -// scaffolding than the one line it protects, so these two stay TEXT-pinned. That is a -// deliberate, written decision, not an oversight — `mod harness`'s header lists -// them as the crate's remaining scan-only transfer guards. -// ------------------------------------------------------------------------------------- - -/// The cross-database eviction must KEEP the instance lease. `delete_row: true` destroys the -/// `game_instance` row and its `game_instance_binding` rows — the bindings that just travelled -/// in the arriving character's blob — so the next party member's `resolve_or_create_instance` -/// mints a SECOND instance and the party is split across two copies of the dungeon. -#[test] -fn the_cross_database_eviction_keeps_the_instance_lease() { - let body = code_of( - include_str!("../instance.rs"), - "pub fn evict_instance_population(", - ); - // ONE teardown call, so the `false` cannot be a decoy sitting next to a live `true` — the - // dead-branch trick that defeats every `contains` scan. - assert_eq!( - body.matches("teardown_instance_inner(").count(), - 1, - "evict_instance_population tears the instance down more than once. It has exactly one \ - teardown, and it keeps the lease. Body was:\n{body}" - ); - assert!( - body.contains("teardown_instance_inner(ctx, instance_id, false)"), - "evict_instance_population no longer keeps the instance LEASE (issue #19 AC#2). \ - Evicting with `delete_row: true` destroys the `game_instance` row and its bindings, \ - so the next member to hop resolves to a BRAND-NEW instance and the party is split \ - across two copies of the run. Body was:\n{body}" - ); -} - /// (group slice) DELETED the interim group mirror — membership is realm-core's, /// and the blob carrying a `begin_transfer` snapshot of it would race the authority (which is /// what made a party SPLIT across the boundary unable to see itself in the first place). @@ -2254,14 +1496,6 @@ fn the_instance_removal_dies_with_the_source_copy_and_is_not_carried() { Character is no longer in" ); assert!(NOT_TRANSPORTED.contains(&"game_instance_removal")); - let source = include_str!("../instance.rs"); - let marker = "fn sweep_delete_game_instance_removal(ctx, character_guid) {"; - let body = &source[source.find(marker).expect("the delete sweep") + marker.len()..]; - let body = &body[..body.find("});").expect("the sweep body closes")]; - assert!( - body.contains(".character_guid().delete(character_guid)"), - "the delete sweep must remove the departing Character's own row. Body was:\n{body}" - ); } /// The tables an older blob may lack, written out: every manifest table added since Core commit diff --git a/module/src/tripwires.rs b/module/src/tripwires.rs index c481df835..8bcbb1a70 100644 --- a/module/src/tripwires.rs +++ b/module/src/tripwires.rs @@ -1182,7 +1182,6 @@ mod character_fence_tripwire { // THE GATE ITSELF. ("module/src/helpers.rs", 2, "`character_by_guid` + `character_by_name` — the fence; these two ARE the raw lookups everything else routes through"), // TRANSFER MACHINERY — must read the row the fence hides, or it could not move it. - ("module/src/transfer/tests.rs", 1, "NOT a lookup: the expected-shape STRING LITERAL inside `the_production_adapter_is_the_pass_through_the_harness_assumes`, which quotes `CtxShard::has_character`'s body verbatim so any edit to it fails. This scanner reads text and is right to count it"), ("module/src/transfer/mod.rs", 4, "TRANSFER MACHINERY, all four reading the row the fence hides because that is the row they exist to move: `in_transit_instances` (the instance reaper's claim census — this call site exists BECAUSE the character is in transit, so a fenced reader would return None every time), `import_character`'s same-database re-partition, and `CtxShard`'s two sink methods — `has_character` (the plan probe, the materialise upsert AND the post-import durability PROOF that the destination copy exists before an in-row licenses deleting the source) and `with_character` (the escrow's own export read, taken after the freeze). #380 dropped this from 8 to 4: the other four were expected-shape STRING LITERALS inside exact-shape pins that quoted `CtxImportSink`'s and `helpers.rs`'s bodies verbatim. The `helpers.rs` ones are gone (all three chokepoints collapsed onto one `gate_by_guid`, whose mutants cargo-mutants CATCHES); the adapter's moved to `transfer/tests.rs`, which is why that file now has a budget of its own"), ("module/src/world.rs", 8, "player_login carries its own `is_in_transit` fence (the re-materialisation chokepoint, 2 reads); teleport_player, set_home, persist_entity and is_gm_character take a guid already resolved through a fenced entity (persist_entity is called BY begin_transfer); debug_delete_character keeps a raw existence probe so a missing character stays a no-op while an in-transit one is REFUSED, with the fence on the same expression; cascade_delete_character's raw row DELETE is the sweep that probe guards. `recall_to_home` is now FENCED (character_by_guid) — it was the one teleport_player caller needing no live entity"), // OPEN, not decided — spec puts group MEMBERSHIP state on realm-core, settled. diff --git a/module/src/vmap.rs b/module/src/vmap.rs index 819d52e9c..7b2408041 100644 --- a/module/src/vmap.rs +++ b/module/src/vmap.rs @@ -1311,107 +1311,6 @@ mod tests { assert!(!tri_is_indoor(&tri(TriClass::M2))); } - /// The presence table is computed inside verification's EXISTING decode pass — the same loop - /// that already validates every staged chunk exactly once — and it changes neither the byte - /// count nor the digest the manifest is checked against. - #[test] - fn verification_marks_indoor_cells_in_its_existing_decode_pass() { - let body = - crate::test_scan::code_of(include_str!("vmap.rs"), "pub fn verify_vmap_generation("); - assert_eq!( - body.matches("decode(&chunk.blob)").count(), - 1, - "verification must decode each staged chunk ONCE — the indoor classification rides that \ - pass, it does not add a second one. Body was:\n{body}" - ); - let decode = body - .find("decode(&chunk.blob)") - .expect("the decode pass exists"); - let classify = body - .find("tris.iter().any(tri_is_indoor)") - .expect("the decode pass must classify the chunk's triangles"); - let manifest = body - .find("require_manifest(") - .expect("verification still checks the manifest"); - let record = body - .find("record_indoor_cells(ctx, generation_id, indoor_cells);") - .expect("verification must record the generation's indoor cells"); - assert!(decode < classify && manifest < record); - assert!( - !body.contains("manifest_digest = ") && !body.contains("expected_bytes ="), - "the derived table must not touch the manifest or the digest. Body was:\n{body}" - ); - } - - /// The indoor query's two fail-open stages, in order: the indexed marker find first (a cell with - /// no indoor geometry never raycasts), then the area probe. Both answer "outdoors" when they - /// cannot answer at all, which is what makes the mount feature correct with vmap off. - #[test] - fn the_indoor_query_pre_rejects_before_it_raycasts() { - let src = include_str!("vmap.rs"); - assert_eq!( - crate::test_scan::shape_of(src, "pub fn is_indoor("), - "{ cell_may_be_indoor(ctx, map_id, x, y) && area_info(ctx, map_id, x, y, z).is_some_and(|info| info.indoor) }", - "`is_indoor` must short-circuit on the per-cell marker before paying an area query" - ); - let cell = crate::test_scan::code_of(src, "fn cell_may_be_indoor("); - assert!( - cell.contains("let Some(generation_id) = active_generation_id(ctx, map_id) else {") - && cell.contains("return false;"), - "a map with no ACTIVE generation must read as outdoors. Body was:\n{cell}" - ); - assert!( - cell.contains("lyracore_shared::terrain::cell_index(x)"), - "an off-map position must read as outdoors through the shared cell index. Body was:\n{cell}" - ); - let area = crate::test_scan::code_of(src, "pub fn area_info("); - assert!( - area.contains("if !vmap_enabled(ctx, map_id) {") && area.contains("return None;"), - "the area query must still fail open with the kill-switch off. Body was:\n{area}" - ); - } - - /// The area query's probe budget is the short one, not the 200 yd floor probe — that reach finds - /// a WMO two storeys below an open-world point and reports the point as inside it. - #[test] - fn the_area_query_uses_the_short_probe_budget() { - let area = crate::test_scan::code_of(include_str!("vmap.rs"), "pub fn area_info("); - assert!( - area.contains("lyracore_shared::vmap::AREA_PROBE_DOWN_YD") - && !area.contains("FLOOR_PROBE_DOWN_YD"), - "the area probe must use the short area budget. Body was:\n{area}" - ); - let floor = crate::test_scan::code_of(include_str!("vmap.rs"), "pub fn floor_z("); - assert!( - floor.contains("FLOOR_PROBE_DOWN_YD"), - "the long floor probe stays on the floor/ray path. Body was:\n{floor}" - ); - } - - /// "Which generation is live for this map" has ONE implementation. `vmap_enabled`, the ray - /// `fetcher` and the indoor-presence lookup all ask it; a second copy of the filter is how two - /// answers start to drift. - #[test] - fn the_active_generation_scan_appears_once() { - let src = include_str!("vmap.rs"); - let scans = src - .match_indices("by_map_state()") - .filter(|(idx, _)| { - !crate::test_scan::on_comment_line(src, *idx) - && !crate::test_scan::in_string_literal(src, *idx) - }) - .count(); - assert_eq!( - scans, 2, - "`by_map_state` may appear exactly twice: `active_generation_id` (the one read) and \ - `activate_vmap_generation`'s own supersede sweep." - ); - assert!(crate::test_scan::code_of(src, "pub fn vmap_enabled(") - .contains("active_generation_id(ctx, map_id)")); - assert!(crate::test_scan::code_of(src, "fn fetcher(") - .contains("active_generation_id(ctx, map_id)")); - } - #[test] fn restaging_requires_the_same_provenance_identities() { let row = VmapGeneration { diff --git a/module/src/weather.rs b/module/src/weather.rs index 19da234da..ce75c3f04 100644 --- a/module/src/weather.rs +++ b/module/src/weather.rs @@ -931,35 +931,6 @@ mod tests { ); } - /// Every reducer in this file must open with its gate — `require_operator` for the operator's - /// lever, the scheduler-only sender fence for the roll — before it reads or writes anything. - /// The `gw.rs` precedent, applied to the file that holds weather's own two reducers. - #[test] - fn every_reducer_here_opens_with_its_gate() { - let src = include_str!("weather.rs"); - // Built at run time so this test's own source can never match the needle. - let needle = format!("#[{}]", "reducer"); - let mut chunks = src.split(needle.as_str()); - chunks.next(); // preamble - let mut seen = 0; - for chunk in chunks { - let body = chunk - .split_once('{') - .map(|(_, body)| body) - .unwrap_or("") - .trim_start(); - assert!( - body.starts_with("require_operator(ctx)?;") - || body.starts_with("if ctx.sender() != ctx.database_identity()"), - "a reducer here must open with require_operator(ctx)?; or the scheduler-only \ - sender fence — got:\n{}", - &body[..body.len().min(120)] - ); - seen += 1; - } - assert_eq!(seen, 2, "the scan found {seen} reducers, expected 2"); - } - #[test] fn the_seeded_climate_covers_the_two_verification_zones_with_valid_percentages() { let zones: Vec = TEMPORARY_ZONE_CLIMATE diff --git a/module/src/world.rs b/module/src/world.rs index 12b30005e..e93e6d759 100644 --- a/module/src/world.rs +++ b/module/src/world.rs @@ -2347,11 +2347,7 @@ mod tests { // the reducer's own formatted source as what the "relay is never gated on the persist decision" // invariant pins against. That invariant — and the fall-damage decision the old inline block used // to hide — are now ordinary value-level unit tests instead of a whitespace-collapsed exact-body - // match that broke on every rename or rustfmt re-wrap. What's left below - // (`movement_update_applies_the_plan_and_never_re_derives_the_persist_gate`) is a MUCH smaller - // presence/wiring scan for the one thing no pure unit test can reach without a `ReducerContext` - // harness (playbook §7): does the reducer actually route through the plan, or silently re-derive - // its own copy of the gate. + // match that broke on every rename or rustfmt re-wrap. #[test] fn resolve_environmental_damage_subtracts_short_of_lethal_and_flags_lethal_without_mutating() { @@ -2455,60 +2451,6 @@ mod tests { assert!(!plan_movement(HEARTBEAT, false, false, false, 0.0, false, &stationary).moved); } - #[test] - fn movement_update_applies_the_plan_and_never_re_derives_the_persist_gate() { - let body = crate::test_scan::code_of( - include_str!("world.rs"), - "pub(crate) fn apply_movement_update(", - ); - // The persist/relay/channel/score decisions must route through `plan_movement` — not a - // second, hand-rolled `snapshot_needs_persist` call inline (that decision, and the "relay is - // never gated on it" invariant, are pinned directly by the tests above instead). - assert_eq!( - body.matches("snapshot_needs_persist(").count(), - 0, - "movement_update must not call snapshot_needs_persist directly — plan_movement owns \ - that decision now. Body was:\n{body}" - ); - let plan_at = body - .find("plan_movement(") - .expect("movement_update must build its plan via plan_movement"); - let entity_at = body - .find("entities.guid().update(mover)") - .expect("the gated entity write"); - assert!( - plan_at < entity_at, - "the plan must be built before the entity row write" - ); - assert_eq!( - body.matches("entities.guid().update(mover)").count(), - 1, - "exactly one entity write" - ); - assert!( - body.contains("if plan.persist_entity {"), - "the entity write must be gated on plan.persist_entity, not re-derived inline. Body \ - was:\n{body}" - ); - // The relay must come after the entity write and be gated on `plan.relay_motion` — never on - // `plan.persist_entity` or anything else re-derived inline. `plan_movement`'s own tests prove - // `relay_motion` is ALWAYS true; this only pins that the reducer actually routes the relay - // through it, which is the one piece no pure unit test (no ReducerContext in this crate) can - // reach on its own. - let relay_at = body - .find("if plan.relay_motion {") - .expect("the motion relay's own (always-true) gate"); - assert!( - entity_at < relay_at, - "the motion relay must come after the gated entity write" - ); - assert_eq!( - body.matches("if plan.relay_motion {").count(), - 1, - "exactly one relay gate" - ); - } - // ---- Work-item 255: movement plausibility (detect-and-flag) ------------------------------ #[test] fn movement_violation_flags_speed_and_teleport_but_not_legit_motion() { @@ -2620,77 +2562,6 @@ mod tests { ); } - /// The CALL SITES, pinned by scan because both live inside `&ReducerContext` functions this - /// crate has no harness for (playbook §7/§8: say so, then pin with the strongest scan). Both - /// needles are struct/row FIELD ASSIGNMENTS, which is what makes a presence scan load-bearing - /// here rather than decorative — Rust forbids a duplicate field in a struct literal, and - /// `persist_entity` writes each column exactly once, so a decoy copy of the needle cannot - /// coexist with a restored hardcode. - /// - /// What it does NOT catch: a `persist_entity` that computes the pair and then never - /// `chars.guid().update(c)`s (covered by the surrounding write, unchanged here), and the - /// gateway-side relay of the restored speed to the client (see the work-item note — the create - /// block hardcodes `speeds::RUN`). - #[test] - fn the_rebuild_and_persist_call_sites_thread_the_gm_playtest_carry_columns() { - let persist = - crate::test_scan::code_of(include_str!("world.rs"), "pub(crate) fn persist_entity("); - assert!( - persist.contains( - "persisted_gm_playtest(entity.godmode, entity.run_speed_mult_bp, set_offline)" - ), - "persist_entity must stamp the carry columns THROUGH the policy fn (passing the live \ - entity's values and this persist's set_offline sense). Body was:\n{persist}" - ); - assert!( - persist.contains("c.pending_godmode = pending_godmode;") - && persist.contains("c.pending_run_speed_mult_bp = pending_speed_bp;"), - "…and assign BOTH results to the durable row — computing them and dropping them on the \ - floor is the 289 bug with extra steps. Body was:\n{persist}" - ); - - let build = crate::test_scan::code_of( - include_str!("creatures/spawn.rs"), - "pub fn build_player_entity(", - ); - assert!( - build.contains("godmode: character.pending_godmode,") - && build.contains("run_speed_mult_bp: character.pending_run_speed_mult_bp,"), - "build_player_entity (the ONLY constructor the WORLDPORT_ACK rebuild runs) must read the \ - carry columns. Body was:\n{build}" - ); - assert!( - !build.contains("godmode: false,") && !build.contains("run_speed_mult_bp: 10_000,"), - "…and must NOT hardcode them again — that hardcode IS work-item 289. Body was:\n{build}" - ); - } - - /// World entry's zone, pinned by scan for the same reason as the carry columns above: - /// `build_player_entity` takes a `&ReducerContext` this crate has no harness for (playbook - /// §7/§8). The needle is a struct FIELD ASSIGNMENT, so a restored `zone_id: 0` hardcode cannot - /// coexist with it — Rust forbids the duplicate field. The behavior across a real database is - /// covered by `module/tests/zone_transition.rs`. - #[test] - fn world_entry_resolves_the_arrival_zone_from_trusted_terrain() { - let build = crate::test_scan::shape_of( - include_str!("creatures/spawn.rs"), - "pub fn build_player_entity(", - ); - assert!( - build.contains( - "zone_id: crate::terrain::zone_id_at(ctx, character.map_id, character.x, character.y)" - ), - "every world-entry path — login, the WORLDPORT_ACK rebuild after a cross-map teleport, \ - and a Transfer arrival's first login — builds the live row here, so the arrival zone \ - must be resolved from terrain before the Gateway can read it. Body was:\n{build}" - ); - assert!( - build.contains(".unwrap_or(character.zone_id)"), - "…and an arrival off the imported terrain slice must fall back to the durable zone, \ - never fail world entry. Body was:\n{build}" - ); - } - #[test] fn ghost_restored_fields_reapply_exactly_the_release_state_over_the_fresh_build() { use lyracore_shared::constants::{player_flags, unit_vis_flags}; @@ -2769,64 +2640,4 @@ mod tests { // The sickness debuff id the res applies is the seeded vanilla Resurrection Sickness (15007). assert_eq!(RESURRECTION_SICKNESS_SPELL, 15007); } - - // ---- defect 1: cascade_delete_character ratchets the guid allocator ------------- - - /// `body_of`/`code_of`/`shape_of` are the shared scan primitives in [`crate::test_scan`] - /// (this used to be six near-identical copies, drifted apart). - use crate::test_scan::shape_of; - - /// Every character-delete path — transfer-driven, CMSG_CHAR_DELETE, `debug_delete_character` — - /// funnels through this ONE function, which is why the guid-allocator ratchet lives here rather - /// than at each call site. - /// - /// This asserts the ratchet is the body's FIRST statement — an exact prefix match, not - /// `.contains()`. `cascade_delete_character` is a large, actively-edited sweep function, so - /// pinning its ENTIRE shape (the technique `auth.rs` uses for its two small wrappers) would be - /// too brittle here; a prefix is cheap AND still closes every round-1 defeat: reordering the - /// ratchet after the sweeps/delete changes what the prefix IS (fails); wrapping the call in a - /// shadowed `{ let character_guid = 0u64; ... }` block inserts a `let` before it (fails); - /// commenting the real call out and replacing it with `let _ = ctx;` puts different code first - /// (fails). `.contains()` on the same body caught none of the three (round-1 review). - /// A deleted character must leave NO combat rows behind, in EITHER direction. The forward half - /// (its own outgoing attack) was always covered; the reverse half was not, and that is what - /// stranded ~100 creatures on a live realm when 300 bots despawned — each still holding a melee - /// row against a guid that no longer existed, which the aggro pass then refuses to re-arm - /// (it skips anything already attacking) and nothing ever disengages. - /// - /// This used to assert the three hand-rolled needles (`melee.attacker_guid().delete`, - /// `melee.by_target().filter`, `threat::clear_for_unit`) that `cascade_delete_character` inlined - /// instead of calling the real `combat::disengage` helper — which is exactly why the hand-roll - /// was free to drift (it silently omitted `disengage`'s IN_COMBAT clear on freed attackers). Now - /// that the body routes through the canonical helper, assert THAT call instead of its innards — - /// pinning the needles again would just let the same drift happen a second time. - #[test] - fn cascade_delete_frees_both_directions_of_combat() { - let body = crate::test_scan::code_of( - include_str!("world.rs"), - "pub(crate) fn cascade_delete_character(", - ); - assert!( - body.contains("crate::combat::disengage(ctx, character_guid)"), - "cascade_delete_character no longer routes combat teardown through the canonical \ - `combat::disengage` helper — issue #365 needs this chokepoint to stay routed through \ - the one place that also clears IN_COMBAT on freed attackers, not a hand-rolled copy of \ - its steps. Body was:\n{body}" - ); - } - - #[test] - fn cascade_delete_character_ratchets_the_guid_allocator_as_its_first_statement() { - let shape = shape_of( - include_str!("world.rs"), - "pub(crate) fn cascade_delete_character(", - ); - let want_prefix = "{ crate::auth::bump_guid_high_water(ctx, character_guid);"; - assert!( - shape.starts_with(want_prefix), - "cascade_delete_character no longer ratchets the guid allocator as its first \ - statement — issue #59 defect 1 needs it to run before EVERY sweep and the final row \ - delete, not merely somewhere in the body. Shape was:\n{shape}" - ); - } } From 445112565c6196fb865bbd03155c628831981f83 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 13:40:44 +0200 Subject: [PATCH 03/21] test(gateway): delete tests that pin source text Delete the `include_str!` scans over the Gateway's handler, Store, view, subscription and `main.rs` sources, and the decoy-defence tests on the `WorldStore` adapter. The Gateway's own `code_of` copy loses its last caller and goes with them. The shared crate's scan of the Module and Gateway proficiency call sites goes too. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/lyracore-shared/src/item.rs | 33 -- gateway/src/accept.rs | 44 -- gateway/src/config.rs | 175 ------ gateway/src/logon/mod.rs | 24 - gateway/src/main.rs | 551 ------------------ gateway/src/realm_core.rs | 106 +--- gateway/src/stdb/reads/account.rs | 19 - gateway/src/stdb/reads/guild.rs | 31 - gateway/src/stdb/reads/party.rs | 63 -- gateway/src/stdb/reducers.rs | 152 ----- gateway/src/stdb/subscriptions.rs | 487 ---------------- gateway/src/stdb/world_store.rs | 403 +------------ gateway/src/stdb/world_view.rs | 252 +------- .../stdb/world_view/cell_audience_tests.rs | 33 -- gateway/src/test_scan.rs | 78 --- gateway/src/world/handlers/meeting_stone.rs | 13 - gateway/src/world/party_tests.rs | 38 -- gateway/src/world/transfer_tests.rs | 121 ---- 18 files changed, 9 insertions(+), 2614 deletions(-) delete mode 100644 gateway/src/test_scan.rs diff --git a/crates/lyracore-shared/src/item.rs b/crates/lyracore-shared/src/item.rs index 8e45701e6..808399381 100644 --- a/crates/lyracore-shared/src/item.rs +++ b/crates/lyracore-shared/src/item.rs @@ -459,37 +459,4 @@ mod tests { ); } } - - /// CROSS-CRATE DERIVATION: the Module's equip Gate and the Gateway's `SMSG_SET_PROFICIENCY` - /// armor mask must both come from this file, through the same spellbook reader. If either grows - /// its own class table, or reads a different spell for the same tier, the client tints an item - /// red that the Gate accepts — or worse, tints one usable that the Gate refuses. No crate can - /// call both call sites, so this owner pins them by source scan. - #[test] - fn both_consumers_derive_their_armor_proficiency_here() { - let call_sites = [ - ( - "the Module equip Gate", - include_str!("../../../module/src/items/inventory.rs"), - ), - ( - "the Gateway armor mask", - include_str!("../../../gateway/src/codec/item.rs"), - ), - ]; - for (name, src) in call_sites { - assert!( - src.contains("Proficiency::from_spellbook("), - "{name} must derive proficiency from the shared spellbook reader" - ); - assert!( - !src.contains("Proficiency::derive("), - "{name} must not assemble the flags itself — that is how the two sides drift" - ); - assert!( - !src.contains("PASSIVE_SPELL_ID"), - "{name} must not name a proficiency passive; `from_spellbook` owns that mapping" - ); - } - } } diff --git a/gateway/src/accept.rs b/gateway/src/accept.rs index 94c39868d..87e27dd1a 100644 --- a/gateway/src/accept.rs +++ b/gateway/src/accept.rs @@ -372,48 +372,4 @@ mod tests { assert!(panicked.unwrap_err().is_panic()); assert_eq!(capacity.available(), 1, "an unwind must return its seat"); } - - #[test] - fn both_listeners_admit_before_they_spawn_a_blocking_task() { - for (listener, source, signature) in [ - ("logon", include_str!("logon/mod.rs"), "pub async fn run("), - ("world", include_str!("world/mod.rs"), "pub async fn run("), - ] { - let body = crate::test_scan::code_of(source, signature); - let admit = body - .find("cfg.blocking_task_capacity.try_admit()") - .unwrap_or_else(|| panic!("{listener} does not check blocking-task capacity")); - let spawn = body - .find("tokio::task::spawn_blocking") - .unwrap_or_else(|| panic!("{listener} does not spawn its blocking task")); - assert!( - admit < spawn, - "{listener} queues the task before it checks capacity" - ); - assert!( - body[spawn..].contains("let _task_permit = task_permit;"), - "{listener} does not hold its permit for the blocking task's whole lifetime" - ); - } - } - - #[test] - fn blocking_task_capacity_uses_the_runtime_pool_ceiling() { - let main_body = - crate::test_scan::code_of(include_str!("main.rs"), "fn main() -> Result<()> {"); - assert!( - main_body.contains("let max_blocking_threads = config::max_blocking_threads();") - && main_body.contains(".max_blocking_threads(max_blocking_threads)"), - "the runtime blocking pool no longer uses the configured ceiling" - ); - - let config_body = - crate::test_scan::code_of(include_str!("config.rs"), "pub fn from_env() -> Self {"); - assert!( - config_body.contains( - "blocking_task_capacity: BlockingTaskCapacity::new(max_blocking_threads())" - ), - "the listener capacity no longer uses the runtime blocking-pool setting" - ); - } } diff --git a/gateway/src/config.rs b/gateway/src/config.rs index 1f441f33b..a0bdc5b12 100644 --- a/gateway/src/config.rs +++ b/gateway/src/config.rs @@ -1297,181 +1297,6 @@ mod shard_map_tests { ); } - /// Strip Rust comments from `src` so a source scan cannot be satisfied by prose. - /// - /// Adversarial review of this PR defeated the tripwire below FOUR ways, and three of them were - /// comments: the original scan filtered only lines whose FIRST token is `//`, so a trailing - /// `// game_config() … find(&0)` on any code line, a `/* … */` block, or a commented-out - /// `// "SELECT * FROM game_config",` all satisfied it while the code did the opposite. Removing - /// comments before scanning is the only version of this that means anything. - /// - /// String literals are left alone (the scan wants `"SELECT * FROM game_config"`), which is why - /// the `?` assertion below is positional rather than a `contains`. - fn strip_comments(src: &str) -> String { - let mut out = String::with_capacity(src.len()); - let mut chars = src.char_indices().peekable(); - let (mut in_str, mut in_line, mut block) = (false, false, 0usize); - while let Some((i, c)) = chars.next() { - if in_line { - if c == '\n' { - in_line = false; - out.push(c); - } - continue; - } - if block > 0 { - if c == '/' && src[i..].starts_with("/*") { - block += 1; - chars.next(); - } else if c == '*' && src[i..].starts_with("*/") { - block -= 1; - chars.next(); - } else if c == '\n' { - out.push(c); - } - continue; - } - if in_str { - out.push(c); - if c == '\\' { - if let Some((_, n)) = chars.next() { - out.push(n); - } - } else if c == '"' { - in_str = false; - } - continue; - } - match c { - '"' => { - in_str = true; - out.push(c); - } - '/' if src[i..].starts_with("//") => in_line = true, - '/' if src[i..].starts_with("/*") => { - block = 1; - chars.next(); - } - _ => out.push(c), - } - } - out - } - - /// The verdict has to be ENFORCED where the databases are known. `Coordinator::connect` needs a - /// live node, so this is the tripwire for its one call site: the pure decision above is worth - /// nothing if the result is dropped on the floor (`let _ = …`, `.ok()`, a bare `if let`). - /// - /// # This scan was defeated four ways in review — do not weaken it back - /// - /// Every assertion here exists because a mutation satisfied its predecessor while reintroducing - /// the empty-dungeon failure with the whole 438-test suite green: - /// - /// 1. **`?` inside a string.** `.enforce().map_err(|m| log::warn!("hosting? {m}"))` contains a - /// `?` and propagates NOTHING. Now the `?` must be the LAST token of the statement, which is - /// what "propagates" actually means and still survives the `.map_err` the real code needs. - /// 2. **Dead binding.** Keeping the real reader closure, adding `let _ = &hosts_instances;`, and - /// passing `|_| Some(true)` to the check left every "the reader names `game_config()` / - /// `find(&0)`" assertion true and the check wired to a constant. Now the call site must pass - /// the READER BY NAME and the read must live inside that binding. - /// 3. **Comment.** `// dropped for now: "SELECT * FROM game_config",` satisfied the subscription - /// assertion with the subscription deleted. Comments are stripped now. - /// 4. **`if false { … }`.** A decoy block satisfies any `contains` scan. Rejected outright. - #[test] - fn coordinator_connect_enforces_the_hosting_verdict_fatally() { - const CONNECTION_RS_RAW: &str = include_str!("stdb/connection.rs"); - let connection_rs = strip_comments(CONNECTION_RS_RAW); - let body = { - let start = connection_rs - .find("pub async fn connect(cfg: &GatewayConfig)") - .expect("Coordinator::connect moved — re-point this tripwire"); - let rest = &connection_rs[start..]; - let open = rest.find('{').expect("connect has a body"); - let mut depth = 0i32; - let mut end = 0usize; - for (i, c) in rest[open..].char_indices() { - match c { - '{' => depth += 1, - '}' => { - depth -= 1; - if depth == 0 { - end = open + i; - break; - } - } - _ => {} - } - } - rest[open..=end].to_string() - }; - // Defeat 4: a decoy block satisfies every `contains` below while running nothing. - assert!( - !body.contains("if false"), - "`if false` in Coordinator::connect — a dead block satisfies every source assertion in \ - this tripwire while executing none of it" - ); - assert!( - body.contains("check_instance_hosting("), - "Coordinator::connect no longer checks instance hosting — a gateway with no shard map \ - against a database with hosts_instances = false is back to creating every dungeon \ - empty, silently" - ); - // Defeat 2: the check must be wired to the NAMED reader, not to an inline closure. Otherwise - // the real read can sit next to it as a dead binding and satisfy the assertions below. - assert!( - body.contains("check_instance_hosting(hosts_instances,"), - "the hosting check is not being passed the `hosts_instances` reader by name — an inline \ - closure (`|_| Some(true)`) keeps every other assertion here true, leaves the real \ - reader as a dead binding, and silently reintroduces the empty-dungeon failure" - ); - let at = body.find(".enforce()").expect( - "the hosting verdict is no longer enforced — `check_instance_hosting` is a pure \ - function whose return value IS the decision", - ); - // Defeat 1: the statement containing `.enforce()` must PROPAGATE. `contains('?')` was - // satisfied by a question mark inside a log-format string; the `?` has to be the last token - // before the `;`, which is the definition of propagating and still allows the intermediate - // `.map_err(...)` the real code needs (`enforce` yields `String`, `connect` yields - // `anyhow::Result`). - let stmt = &body[at..]; - let stmt = &stmt[..stmt.find(';').unwrap_or(stmt.len())]; - assert!( - stmt.trim_end().ends_with('?'), - "the enforced verdict must PROPAGATE — the statement has to END in `?` so `NoHost` \ - aborts startup. `let _ = …`, `.ok()`, or a `?` buried in a log string swallows the \ - fatal and reintroduces the empty-dungeon failure with every other test green: {stmt}" - ); - // Defeat 3: comments are stripped, so this now needs the real subscription. It lives in - // `coordinator_queries`, not in `connect`'s body, so this one assertion scans the whole file. - assert!( - connection_rs.contains("\"SELECT * FROM game_config\""), - "the coordinator no longer subscribes game_config — the detector cannot read the flag \ - back and degrades to the old guess-and-warn reminder" - ); - // The flag reader must actually READ THE COLUMN, from the id=0 SINGLETON — and the read must - // be INSIDE the `hosts_instances` binding that the call site above passes by name, so the two - // cannot be decoupled. The module-side twin of the `find(&0)` mutation (any other id finds - // nothing and defaults to hosting) is recorded in `module/src/instance.rs` as one that - // SURVIVED review once already. - let reader = { - let from = body - .find("let hosts_instances =") - .expect("the `hosts_instances` reader binding is gone — see the assertion above"); - let rest = &body[from..]; - &rest[..rest.find("};").map(|e| e + 2).unwrap_or(rest.len())] - }; - assert!( - reader.contains("game_config()") && reader.contains("hosts_instances"), - "the `hosts_instances` reader does not read game_config out of the coordinator cache — \ - the hosting check is wired to a constant: {reader}" - ); - assert!( - reader.contains("find(&0)"), - "the flag reader is not looking at the game_config id=0 singleton — it would find \ - nothing, default to `true`, and never report an empty-dungeon realm: {reader}" - ); - } - #[test] fn a_full_wildcard_rule_moves_the_whole_world() { let m = ShardMap::parse("world", "*:*=elsewhere"); diff --git a/gateway/src/logon/mod.rs b/gateway/src/logon/mod.rs index 7e1fcecb1..abe0499f9 100644 --- a/gateway/src/logon/mod.rs +++ b/gateway/src/logon/mod.rs @@ -2383,27 +2383,3 @@ mod tests { } } } - -/// The realm list needs a live `Coordinator`, so its use of the shared resolution is pinned by a -/// scan rather than a unit test — the same reason `main.rs`'s startup wiring is. -#[cfg(test)] -mod realm_list_resolution_tripwire { - use crate::test_scan::code_of; - - /// Both the realm list and the startup check must read one resolution. Inlining the override - /// here again would let the warning describe an address no client was ever given. - #[test] - fn the_realm_list_advertises_through_the_shared_resolution() { - let src = include_str!("mod.rs"); - let body = code_of( - src, - "fn realms(&self, account_id: u64, username: &str) -> Result> {", - ); - assert!( - body.contains("address: crate::config::advertised_realm_address_or(realm.address),"), - "the realm list no longer resolves the advertised address through \ - `advertised_realm_address_or`, so the row and the startup warning can disagree. \ - Body was:\n{body}" - ); - } -} diff --git a/gateway/src/main.rs b/gateway/src/main.rs index a63dbda54..e35afc58d 100644 --- a/gateway/src/main.rs +++ b/gateway/src/main.rs @@ -25,8 +25,6 @@ mod provision_cli; mod read_deadline; mod realm_core; mod stdb; -#[cfg(test)] -mod test_scan; mod world; use anyhow::Result; @@ -225,552 +223,3 @@ async fn provision(cfg: &GatewayConfig, username: &str, password: &[u8]) -> Resu ); Ok(()) } - -/// Bot-invite-relay wiring tripwires: five behavioural tests (`gateway/src/world/party_tests.rs`) -/// already pin `world::party::run_bot_invite` — a pure function called directly, with no coordinator, no -/// subscription, no `main` in the loop. Nothing in that suite would notice any of the SIX call sites -/// below vanishing; a live coordinator (the only thing that WOULD notice) is out of reach for a unit -/// test. So each site's PRESENCE — not its sense, there is no boolean guard here to invert — is pinned -/// by a source scan, following `character_fence_tripwire`'s precedent and `test_scan::code_of`'s -/// comment-stripping (a bare `.contains()` on an un-stripped body is exactly what a trailing-comment -/// needle defeats). Tests 4-5 additionally pin ORDER, not just presence — the -/// find-offset ordering-tripwire style `world_store.rs`'s `routing_call_site_tests` use — because -/// the reconnect hook only works if it runs after the connection swap it depends on. -/// -/// Confirmed by mutation: deleting each of the six independently turns exactly one of these red, with -/// every other suite (including the 5 `run_bot_invite` tests) staying fully green. -/// A provisioned account has to exist on BOTH databases, for two unrelated reasons, and dropping -/// either one produces a failure that looks like something else entirely. -/// -/// This is a source scan for the same reason the tripwires below are: `provision` needs a live -/// `Coordinator` against a real node, which no unit test can construct. A scan cannot catch a sense -/// inversion — but there is no boolean here to invert, only two call sites whose *presence* is the -/// whole behaviour, which is precisely what a scan does catch. -#[cfg(test)] -mod provision_writes_both_databases_tripwire { - use crate::test_scan::code_of; - - /// Losing the realm-core write makes every account on a SHARDED realm unloggable while - /// `provision` still reports success: the SRP6 challenge is answered from realm-core - /// (`realm_core.rs::the_srp6_challenge_material_comes_from_realm_core_never_the_world_shards_cache`), - /// so the logon server looks the account up there and finds nothing. It cost a full - /// capacity-benchmark run — 200 accounts, every login refused, `rung 50: 0 in world, 50 failed`. - #[test] - fn provision_writes_the_account_to_realm_core_too() { - let src = include_str!("main.rs"); - let body = code_of(src, "async fn provision(cfg: &GatewayConfig, username: &str, password: &[u8]) -> Result<()> {"); - assert!( - body.contains("let rc = coordinator.realm_core()?;") - && body.contains("rc.provision_account(&user, &salt, &pw_verifier)?;"), - "`provision` no longer writes the account to realm-core. On a sharded realm the account \ - is then invisible to the logon server — provisioning reports success and every login \ - is refused. Body was:\n{body}" - ); - } - - /// And losing the world-shard write breaks the other half: `Account.id` is `#[auto_inc]`, a - /// per-database surrogate key, and `characters`/`player_login`/`create_character` all mean the - /// WORLD shard's id. Without it the account authenticates and then owns no characters. - #[test] - fn provision_still_writes_the_account_to_the_world_shard() { - let src = include_str!("main.rs"); - let body = code_of(src, "async fn provision(cfg: &GatewayConfig, username: &str, password: &[u8]) -> Result<()> {"); - assert!( - body.contains("coordinator.provision_account(&user, &salt, &pw_verifier)?;"), - "`provision` no longer writes the account to the world shard — the `#[auto_inc]` \ - account id that owns characters is never created there. Body was:\n{body}" - ); - } -} - -/// The predicate's cases are pinned directly in `config::realm_address_tests`. What no unit test can -/// reach is whether anything ever ASKS it: the check needs a live coordinator, so its presence in -/// `main` is pinned by a scan the way every other startup wiring here is. -#[cfg(test)] -mod advertised_address_warning_wiring_tripwire { - use crate::test_scan::code_of; - - #[test] - fn main_checks_the_advertised_address_at_startup() { - let src = include_str!("main.rs"); - let body = code_of(src, "async fn run() -> Result<()> {"); - assert!( - body.contains("warn_on_unreachable_realm_address(&coordinator, &cfg);"), - "`main` no longer checks the advertised realm address, so a realm seeded with the \ - loopback default starts entirely green while no remote client can enter the world. \ - Body was:\n{body}" - ); - } - - /// The check must sit after the coordinator exists, or it reads no realm row and silently - /// never fires. - #[test] - fn the_check_runs_after_the_coordinator_is_connected() { - let src = include_str!("main.rs"); - let body = code_of(src, "async fn run() -> Result<()> {"); - let connect = body - .find("let coordinator = stdb::Coordinator::connect(&cfg).await?;") - .expect("main still connects the coordinator"); - let check = body - .find("warn_on_unreachable_realm_address(") - .expect("main still checks the advertised address"); - assert!( - connect < check, - "the advertised-address check now runs BEFORE the coordinator connects, so it reads \ - no realm row and the warning can never fire" - ); - } - - /// A warning naming neither address, or no way to fix it, sends the reader back to the docs — - /// which is the hour this line exists to save. - #[test] - fn the_warning_names_both_addresses_and_the_way_to_change_it() { - let src = include_str!("main.rs"); - let body = code_of(src, "fn warn_on_unreachable_realm_address("); - for needle in ["{advertised}", "cfg.world_bind", "set_realm_address"] { - assert!( - body.contains(needle), - "the warning no longer carries `{needle}`, so it cannot be acted on without \ - opening documentation. Body was:\n{body}" - ); - } - } -} - -#[cfg(test)] -mod bot_invite_relay_wiring_tripwire { - use crate::test_scan::code_of; - - /// 1. Nothing arms the relay at all unless `main` calls it once at startup. - #[test] - fn main_arms_the_bot_invite_relay_at_startup() { - let src = include_str!("main.rs"); - let body = code_of(src, "async fn run() -> Result<()> {"); - assert!( - body.contains("coordinator.spawn_bot_invite_relay();"), - "`main` no longer calls `spawn_bot_invite_relay` — nothing subscribes \ - `game_bot_invite_intent` at all, so a bot's serendipity invite is never picked up by \ - ANY gateway, on ANY topology. Body was:\n{body}" - ); - } - - /// 2. The subscription itself: without this the coordinator never even asks the node for the - /// table's rows, so `arm_bot_invite_relay`'s `on_insert` never fires regardless of #1. - #[test] - fn coordinator_subscribes_the_bot_invite_intent_table() { - let src = include_str!("stdb/connection.rs"); - let body = code_of( - src, - "fn coordinator_queries(sharded_tables: bool) -> Vec<&'static str> {", - ); - assert!( - body.contains("\"SELECT * FROM game_bot_invite_intent\","), - "`coordinator_queries` no longer subscribes `game_bot_invite_intent` — the table exists \ - and the module writes to it, but no gateway connection is asking for its rows. Body \ - was:\n{body}" - ); - } - - /// 3. The consumer: the `on_insert` closure that turns a subscribed row into the actual party op. - /// Without this, #1 and #2 together still deliver the row to the coordinator's cache — and - /// nothing acts on it. - #[test] - fn the_on_insert_callback_actually_executes_the_intent() { - let src = include_str!("stdb/subscriptions.rs"); - let body = code_of(src, "fn arm_bot_invite_relay(&self) {"); - let normalized: String = body.split_whitespace().collect::>().join(" "); - assert!( - normalized.contains( - "let (intent_id, op, inviter_guid, target_guid) = (row.id, row.op, \ - row.inviter_guid, row.target_guid);" - ) && normalized.contains( - "crate::world::party::run_bot_invite_intent( &store, intent_id, op, \ - inviter_guid, target_guid, )" - ), - "`arm_bot_invite_relay`'s `on_insert` callback no longer calls \ - `run_bot_invite_intent` with the subscribed row. A bot's \ - invite decision arrives at the gateway and is silently dropped. Body was:\n{body}" - ); - } - - /// 4. The reconnect re-arm — the fix for the ONLY coordinator relay with no self-healing path. - /// Every sibling relay is armed from inside a per-session login, so a login after a reconnect - /// re-arms it implicitly; this one runs once at startup and would otherwise stay bound to a dead - /// `LiveConn` forever. The watchdog treats a SpacetimeDB migration — i.e. a routine module - /// republish — as a reconnect, so losing this hook means serendipity invites work for one session - /// and then go silent, logged at `debug!` where nobody sees it. That is the exact failure this - /// whole issue is about, so the fix for it gets pinned too. - #[test] - fn spawn_bot_invite_relay_installs_the_reconnect_hook() { - let src = include_str!("stdb/subscriptions.rs"); - let body = code_of(src, "pub fn spawn_bot_invite_relay(&self) {"); - assert!( - body.contains("on_reconnect") && body.contains("arm_bot_invite_relay();"), - "`spawn_bot_invite_relay` no longer installs the per-shard `on_reconnect` re-arm. The \ - relay still works until the first coordinator reconnect, then goes permanently silent \ - with no error — the same symptom, and the same invisibility, as the bug this relay's own fix addressed. \ - Body was:\n{body}" - ); - } - - /// 5. The other half of #4: the watchdog has to actually CALL the hook, and call it AFTER the - /// connection swap — the hook re-reads `coord()`, so invoking it before the swap would re-arm on - /// the dead connection and look identical to not re-arming at all. Position is asserted, not just - /// presence, in the style of `world_store.rs`'s find-offset ordering tripwires. - #[test] - fn the_watchdog_invokes_the_reconnect_hook_after_the_swap() { - let src = include_str!("stdb/connection.rs"); - let body = code_of(src, "fn spawn_coordinator_watchdog("); - let replacement_at = body.find("replace_live_conn(").expect( - "the watchdog no longer installs the fresh LiveConn through the shared replacement \ - operation — reconnect is broken", - ); - let hook_at = body.find("hook()").expect( - "the watchdog no longer invokes the `on_reconnect` hook, so every one-shot coordinator \ - relay silently dies at the first reconnect", - ); - assert!( - replacement_at < hook_at, - "the `on_reconnect` hook is invoked BEFORE the shared connection replacement. The hook re-reads \ - `coord()`, so it would re-arm on the dead connection — indistinguishable at runtime \ - from never re-arming, and just as silent." - ); - } -} - -/// The session-less crossing dispatcher, pinned from startup through its bounded current-cache -/// pass. Unlike the Group Intent callback, its thread survives a connection swap. -#[cfg(test)] -mod bot_transfer_relay_wiring_tripwire { - use crate::test_scan::code_of; - - /// 1. Nothing arms the relay at all unless `main` calls it once at startup. - #[test] - fn main_arms_the_bot_transfer_relay_at_startup() { - let src = include_str!("main.rs"); - let body = code_of(src, "async fn run() -> Result<()> {"); - assert!( - body.contains("coordinator.spawn_bot_transfer_relay();"), - "`main` no longer calls `spawn_bot_transfer_relay` — nothing subscribes \ - `game_bot_transfer_intent`, so a session-less character can never cross a Shard \ - boundary on ANY topology. Body was:\n{body}" - ); - } - - /// 2. The subscription itself: without it the coordinator never asks the node for the table's - /// rows, so the `on_insert` never fires regardless of #1. - #[test] - fn coordinator_subscribes_the_bot_transfer_intent_table() { - let src = include_str!("stdb/connection.rs"); - let body = code_of( - src, - "fn coordinator_queries(sharded_tables: bool) -> Vec<&'static str> {", - ); - assert!( - body.contains("\"SELECT * FROM game_bot_transfer_intent\","), - "`coordinator_queries` no longer subscribes `game_bot_transfer_intent` — the module \ - writes the row and no gateway connection is asking for it. Body was:\n{body}" - ); - } - - /// 3. The consumer: the bounded dispatcher that turns a subscribed row into the actual - /// escrowed Transfer. - #[test] - fn the_dispatcher_actually_executes_the_intent() { - let src = include_str!("stdb/subscriptions.rs"); - let body = code_of( - src, - "fn dispatch_bot_transfer_intents(&self, after_id: u64) -> u64 {", - ); - assert!( - body.contains("attempt_bot_transfer(self, &intent);") - && body.contains("rotate_transfer_work(&mut pending, after_id") - && body.contains("next_after = row.id;"), - "the bounded bot Transfer dispatcher no longer attempts the subscribed intent. Body \ - was:\n{body}" - ); - } - - /// 4. Every pass reads through the swappable current connection, so reconnect does not strand - /// resident durable rows behind a dead callback. - #[test] - fn spawn_bot_transfer_relay_polls_the_current_connection() { - let src = include_str!("stdb/subscriptions.rs"); - let body = code_of(src, "pub fn spawn_bot_transfer_relay(&self) {"); - assert!( - body.contains("dispatch_bot_transfer_intents") - && body.contains("Duration::from_millis(100)"), - "`spawn_bot_transfer_relay` no longer polls each World Shard. Body was:\n{body}" - ); - } -} - -/// The character-gone relay: the same connection-callback contract as the Group Intent relay. -#[cfg(test)] -mod character_gone_relay_tripwires { - use crate::test_scan::code_of; - - #[test] - fn main_arms_the_character_gone_relay_at_startup() { - let src = include_str!("main.rs"); - let body = code_of(src, "async fn run() -> Result<()> {"); - assert!( - body.contains("coordinator.spawn_character_gone_relay();"), - "`main` no longer calls `spawn_character_gone_relay` — a Character deleted on a Shard \ - stays in its realm-core party and Guild forever. Body was:\n{body}" - ); - } - - #[test] - fn the_on_delete_callback_moves_party_cleanup_off_the_coordinator_pump() { - let src = include_str!("stdb/subscriptions.rs"); - let body = code_of(src, "fn arm_character_gone_relay(&self) {"); - assert!( - body.contains("store.request_deleted_character_cleanup(row.guid)") - && !body.contains("party::cleanup_deleted_character(") - && !body.contains("forget_deleted_character("), - "the row-delete callback must leave the Coordinator pump before cleanup sends Durable \ - Requests back to the deleting Shard. Body was:\n{body}" - ); - let worker = code_of(src, "fn start_deleted_character_reconciliation(&self) {"); - assert!( - worker.contains("std::thread::Builder::new()") - && worker.contains("deleted_character_reconciliation_running") - && worker.contains("party::reconcile_deleted_character_parties(&store)") - && worker.contains("reconcile_deleted_guild_characters(&store, &guild_work)"), - "deleted Character cleanup of parties and guilds must use one coalescing worker \ - thread. Body was:\n{worker}" - ); - } - - #[test] - fn spawn_character_gone_relay_installs_the_reconnect_hook() { - let src = include_str!("stdb/subscriptions.rs"); - let body = code_of(src, "pub fn spawn_character_gone_relay(&self) {"); - assert!( - body.contains("on_reconnect") - && body.contains("arm_character_gone_relay();") - && body.contains("self.realm_core()") - && body.contains("request_deleted_character_reconciliation();"), - "`spawn_character_gone_relay` no longer installs the per-shard `on_reconnect` re-arm; \ - both World Shard and Realm-core reconnects must schedule deferred cleanup off the \ - parked Coordinator pump. Body was:\n{body}" - ); - let worker = code_of(src, "fn start_deleted_character_reconciliation(&self) {"); - assert!( - worker.contains("std::thread::Builder::new()") - && worker.contains("Duration::from_secs(5)") - && worker.contains("party::reconcile_deleted_character_parties(&store)") - && worker.contains("reconcile_deleted_guild_characters(&store, &guild_work)"), - "reconciliation must run on its worker thread. Body was:\n{worker}" - ); - } -} - -/// The Roster Revision Relay: armed at startup, re-armed on a Realm-core reconnect, and its -/// callbacks never call a reducer. -#[cfg(test)] -mod roster_revision_relay_tripwires { - use crate::test_scan::code_of; - - #[test] - fn main_arms_the_roster_revision_relay_at_startup() { - let src = include_str!("main.rs"); - let body = code_of(src, "async fn run() -> Result<()> {"); - assert!( - body.contains("coordinator.spawn_roster_revision_relay();"), - "`main` no longer calls `spawn_roster_revision_relay`; a party Realm-core changes \ - without a session op stays stale on every World Shard mirror. Body was:\n{body}" - ); - } - - #[test] - fn spawn_roster_revision_relay_installs_the_realm_core_reconnect_hook() { - let src = include_str!("stdb/subscriptions.rs"); - let body = code_of(src, "pub fn spawn_roster_revision_relay(&self) {"); - assert!( - body.contains("self.realm_core()") - && body.contains("realm.arm_roster_revision_relay(&relay);") - && body.contains("on_reconnect") - && body.contains("hook_realm.arm_roster_revision_relay(&relay);"), - "`spawn_roster_revision_relay` must arm Realm-core now and again on every Realm-core \ - reconnect, or the relay goes silent at the first reconnect. Body was:\n{body}" - ); - } - - #[test] - fn the_callbacks_only_mark_the_party_dirty() { - let src = include_str!("stdb/subscriptions.rs"); - let body = code_of(src, "fn arm_roster_revision_relay("); - let normalized: String = body.split_whitespace().collect::>().join(" "); - assert!( - normalized.contains(".on_insert(move |_ctx, row| inserted.mark_dirty(row.group_id));") - && normalized.contains( - ".on_update(move |_ctx, _old, row| updated.mark_dirty(row.group_id));" - ) - && !normalized.contains("sync_group_mirror"), - "the Realm-core callbacks must mark the party dirty and return; a reducer call there \ - blocks the Coordinator pump. Body was:\n{body}" - ); - } -} - -/// The untrusted-input boundaries stay free of panic constructs. -/// -/// The release-boundary hardening audit found ZERO reachable panics in the three files BOUNDARIES -/// actually scans below: the UNAUTHENTICATED logon SRP6 handshake, the one hand-rolled client-byte decoder -/// (`codec/addon.rs`), and the shard-map/env parsing (`config.rs`) all propagate errors instead. -/// That is a property worth KEEPING, not a one-time finding: a single `.unwrap()` added to any of -/// these files turns a malformed packet from "this session closes" into "this session thread -/// unwinds", or a typo'd env var from "config falls back" into "the process aborts at startup". -/// -/// This is narrower than the untrusted-input surface as a whole: it does NOT cover the -/// post-authentication packet path (`world/mod.rs`, including its frame-read loop) or the -/// DB-callback files (`stdb/subscriptions.rs`, `stdb/world_view.rs`), which also turn -/// client- and DB-supplied bytes into values. A manual read (2026-08-10) found those files' hot paths -/// disciplined, but their release code carries dozens of real `.unwrap()`s overall (mostly -/// `RwLock`/`Mutex` poisoning, not remote-input parsing) — far past what BOUNDARIES' all-or-nothing -/// per-file gate can absorb without turning this into a refactor. A clean spot in one of those -/// files is a fact about that file today, not a property this test enforces; extending BOUNDARIES -/// to them is future work, not a documentation fix. -/// -/// A source scan is the right tool for the same reason as the tripwires above — the alternative is -/// driving thread/FD exhaustion and every malformed-frame shape against a live node — and it is a -/// sound one for THIS property, because the forbidden constructs are lexical. Its ceiling is equally -/// lexical: a panic reached through a helper in another file, or through indexing (`v[i]`), is -/// invisible to it. It makes the lazy path fail; it is not an analysis. -#[cfg(test)] -mod boundary_panic_tripwire { - /// `(path, source, what it parses)`. Every file whose RELEASE half turns bytes or configuration - /// this server does not control into values. - const BOUNDARIES: &[(&str, &str, &str)] = &[ - ( - "logon/mod.rs", - include_str!("logon/mod.rs"), - "the UNAUTHENTICATED SRP6 handshake — every byte here arrives before the client has \ - proven anything at all", - ), - ( - "codec/addon.rs", - include_str!("codec/addon.rs"), - "the only hand-rolled decoder of CLIENT bytes in `codec/` (everything else there builds \ - server frames); its slice math is driven by a client-supplied NUL position", - ), - ( - "codec/auth_session.rs", - include_str!("codec/auth_session.rs"), - "the plaintext CMSG_AUTH_SESSION frame — read before the world peer has proven \ - anything, sized by a client header", - ), - ( - "config.rs", - include_str!("config.rs"), - "`LYRACORE_SHARD_MAP` / `LYRACORE_REALM_CORE` parsing — a \ - typo in an operator's env must degrade, never abort the process at startup", - ), - ]; - - /// Constructs that turn a bad input into an unwind instead of an error. `.unwrap()` catches the - /// `Option`/`Result` form; `unwrap_or` / `unwrap_or_else` / `unwrap_or_default` are deliberately - /// NOT matched (they are the fix, not the problem) — hence the closing paren in the needle. - const FORBIDDEN: &[&str] = &[ - ".unwrap()", - ".expect(", - "panic!(", - "unreachable!(", - "todo!(", - "unimplemented!(", - ]; - - /// Drop every `#[cfg(test)]`-gated item, then every comment — so a doc comment that MENTIONS - /// `panic!` and a test that legitimately unwraps both stay out of the scan. - /// - /// Brace-matched rather than truncate-at-the-first-`#[cfg(test)]`: these files may gate - /// individual functions on `test` (config.rs historically did), not only a trailing - /// `mod tests`, so a truncating scan would silently stop reading most of such a file. - fn release_code(src: &str) -> String { - let lines: Vec<&str> = src.lines().collect(); - let mut out = String::new(); - let mut i = 0; - while i < lines.len() { - let t = lines[i].trim(); - if t.starts_with("#[cfg(test)]") || t.starts_with("#[cfg(all(test") { - i += 1; - // Skip any further attributes / docs attached to the same item. - while i < lines.len() { - let u = lines[i].trim(); - if u.starts_with('#') || u.starts_with("//") || u.is_empty() { - i += 1; - } else { - break; - } - } - // Then the item itself: brace-matched, or up to the `;` of a `mod tests;`-style one. - let mut depth = 0i32; - let mut opened = false; - while i < lines.len() { - let l = lines[i]; - depth += l.matches('{').count() as i32; - if l.contains('{') { - opened = true; - } - depth -= l.matches('}').count() as i32; - i += 1; - if opened && depth <= 0 { - break; - } - if !opened && l.trim_end().ends_with(';') { - break; - } - } - continue; - } - out.push_str(crate::test_scan::strip_comment(lines[i])); - out.push('\n'); - i += 1; - } - out - } - - #[test] - fn no_panic_construct_sits_on_an_untrusted_input_boundary() { - for (path, src, what) in BOUNDARIES { - let code = release_code(src); - for needle in FORBIDDEN { - assert!( - !code.contains(needle), - "`{path}` release code now contains `{needle}`. That file is {what}, so a panic \ - there is a remote (or operator) input turning into an unwound thread instead of \ - a closed connection and a log line — this file's boundary rule. Return an error \ - (`?` / `map_err` / `ok_or_else`), or degrade with `unwrap_or*`. If it really is \ - an impossible invariant, it does not belong in a file on this list: move the \ - code, or take the file off BOUNDARIES with a written reason." - ); - } - } - } - - /// The scan is only as good as its stripper: if `release_code` ever stopped removing - /// `#[cfg(test)]` items or comments, the test above would fail on this crate's own tests and the - /// tempting "fix" would be to shorten BOUNDARIES. Pin both behaviors directly. - #[test] - fn release_code_drops_cfg_test_items_and_comments() { - let src = concat!( - "fn keep() { let a = 1; }\n", - "// a comment mentioning .unwrap()\n", - "#[cfg(test)]\n", - "mod tests {\n fn t() { x.unwrap(); }\n}\n", - "fn also_keep() {}\n", - ); - let out = release_code(src); - assert!( - !out.contains(".unwrap()"), - "stripper leaked an unwrap: {out}" - ); - assert!(out.contains("fn keep()"), "stripper ate live code: {out}"); - assert!( - out.contains("fn also_keep()"), - "stripper did not resume after the cfg(test) item: {out}" - ); - } -} diff --git a/gateway/src/realm_core.rs b/gateway/src/realm_core.rs index b00f006bc..8787358c5 100644 --- a/gateway/src/realm_core.rs +++ b/gateway/src/realm_core.rs @@ -23,13 +23,8 @@ //! this file is about. //! //! What is NOT modelled here is the transport: `Coordinator`'s own one-line bodies (the websocket -//! read, the `call_reducer!`) are substituted wholesale by the fake. That layer is pinned by -//! exact-shape equality on the forwarding impl (`the_coordinator_forwards_are_views_not_logic`), -//! because a `contains` scan is defeated by leaving the text in a dead branch. The module pins its -//! own equivalent (`CtxShard`) exactly the same way, and a later measurement showed why that is still the right -//! instrument: a cargo-mutants run over that surface MISSED every mutation in the adapter, because a -//! mutation tool can only ask whether a test fails and no headless test can drive the real -//! connection. The same holds here. +//! read, the `call_reducer!`) are substituted wholesale by the fake. A headless test cannot drive +//! the real connection. use anyhow::{anyhow, Result}; @@ -2486,101 +2481,4 @@ mod tests { best-effort, independently-committing write this replication exists to remove" ); } - - // ------------------------------------------------------------------------------------- - // The seam's own blind spot: `Coordinator`'s forwarding impl - // ------------------------------------------------------------------------------------- - - /// `impl RealmDb for Coordinator` is the ONE layer the fake substitutes for wholesale, and - /// every method in it is a one-line forward whose damage would be total and silent — pointing - /// `realm_core()` at `self`, or `session_key` at the wrong table, is invisible to every test - /// above. So it is compared for EXACT SHAPE, the same way `module/src/transfer/mod.rs` pins - /// `CtxShard`: a `contains` scan is defeated by leaving the old text in a dead branch, equality - /// is not. If a change here is deliberate, re-bless it with the same care. - /// - /// `has_escrow` adapts its inherent return shape by narrowing `Option` to a bool. - /// The other methods forward their inherent result without choosing a database or changing the - /// returned fact. - #[test] - fn the_coordinator_forwards_are_views_not_logic() { - let src = include_str!("stdb/world_store.rs"); - let at = src - .find("impl crate::realm_core::RealmDb for Coordinator {") - .expect("`impl RealmDb for Coordinator` moved out of world_store.rs"); - let body = &src[at..]; - let end = body.find("\n}\n").expect("unterminated impl block"); - let shape = body[..end] - .lines() - .filter(|l| !l.trim_start().starts_with("//")) - .collect::>() - .join(" ") - .split_whitespace() - .collect::>() - .join(" "); - let want = "impl crate::realm_core::RealmDb for Coordinator { \ - fn shard_name(&self) -> &str { self.shard_name() } \ - fn is_sharded(&self) -> bool { self.is_sharded() } \ - fn shard_map(&self) -> &crate::config::ShardMap { self.shard_map() } \ - fn realm_core(&self) -> Result { self.realm_core() } \ - fn world_shards(&self) -> Vec<(String, Coordinator)> { self.world_shards() } \ - fn account_by_username(&self, username: &str) -> Result> { \ - self.account_by_username(username) } \ - fn session_key(&self, account_id: u64) -> Result> { \ - self.session_key(account_id) } \ - fn bound_identity(&self, account_id: u64) -> Result<[u8; 32]> { \ - self.bound_identity(account_id) } \ - fn character_count(&self, account_id: u64) -> Result { \ - self.character_count(account_id) } \ - fn realm(&self) -> Result { self.realm() } \ - fn establish_session( &self, account_id: u64, session_key: &[u8; 40], bound_identity: [u8; 32], ) \ - -> Result<()> { self.establish_session(account_id, session_key, bound_identity) } \ - fn request_gm_command( &self, actor_guid: u64, alpha_test_tools: bool, text: String, ) \ - -> Result<()> { self.request_gm_command(actor_guid, alpha_test_tools, text) } \ - fn character_location(&self, guid: u64) -> Option<(u32, u64)> { \ - self.character_location(guid) } \ - fn character_shard(&self, guid: u64) -> Option<(u32, u64)> { self.character_shard(guid) } \ - fn set_character_shard(&self, guid: u64, map_id: u32, instance_id: u64) -> Result<()> { \ - self.set_character_shard(guid, map_id, instance_id) } \ - fn realm_character_partition( &self, guid: u64, ) \ - -> Result> { \ - self.realm_character_partition(guid) } \ - fn begin_character_shard_transfer( &self, source_map: u32, source_instance: u64, \ - source_revision: u64, destination_map: u32, destination_instance: u64, \ - source_module_identity: spacetimedb_sdk::Identity, intent_id: u64, \ - controller_generation: u64, character_guid: u64, ) -> Result<()> { \ - self.begin_character_shard_transfer( source_map, source_instance, source_revision, \ - destination_map, destination_instance, source_module_identity, intent_id, \ - controller_generation, character_guid, ) } \ - fn finish_character_shard_transfer( &self, \ - intent: &crate::world::transfer::BotTransferIntent, ) -> Result<()> { \ - self.finish_character_shard_transfer(intent) } \ - fn finish_player_character_shard_transfer( &self, character_guid: u64, \ - source_map: u32, source_instance: u64, source_revision: u64, destination_map: u32, \ - destination_instance: u64, ) -> Result<()> { \ - self.finish_player_character_shard_transfer( character_guid, source_map, \ - source_instance, source_revision, destination_map, destination_instance, ) } \ - fn finish_pending_character_shard_transfer( &self, character_guid: u64, \ - source_map: u32, source_instance: u64, source_revision: u64, destination_map: u32, \ - destination_instance: u64, source_module_identity: spacetimedb_sdk::Identity, \ - transfer_intent_id: u64, controller_generation: u64, ) -> Result<()> { \ - self.finish_pending_character_shard_transfer( character_guid, source_map, \ - source_instance, source_revision, destination_map, destination_instance, \ - source_module_identity, transfer_intent_id, controller_generation, ) } \ - fn has_escrow(&self, guid: u64) -> bool { self.escrow_row(guid).is_some() } \ - fn session_count(&self) -> usize { self.session_count() } \ - fn record_shard_load( &self, shard: &str, writer_occupancy_pct: f32, sessions: u32, \ - gateway_key: u64, ) -> Result<()> { self.record_shard_load(shard, \ - writer_occupancy_pct, sessions, gateway_key) }" - .split_whitespace() - .collect::>() - .join(" "); - assert_eq!( - shape, want, - "`impl RealmDb for Coordinator` is no longer a block of pass-throughs to \ - `Coordinator`'s own inherent methods. Everything in `realm_core.rs` is tested through \ - this impl with a fake substituted for it, so an edit here — `realm_core()` returning \ - `self.clone()`, `session_key` reading the wrong account — is invisible to all of it \ - while the whole suite stays green." - ); - } } diff --git a/gateway/src/stdb/reads/account.rs b/gateway/src/stdb/reads/account.rs index a57f75e61..eb4d6872b 100644 --- a/gateway/src/stdb/reads/account.rs +++ b/gateway/src/stdb/reads/account.rs @@ -711,23 +711,4 @@ mod tests { "an owner with no rows left ignores nobody" ); } - - /// `ignored_guids` answers for ANY owner, including one with no live World Session on this - /// Gateway process at all — a whisper sender's target or a guild-invite target usually is not - /// one. Pinned in source (no Fake reaches a real `Coordinator`): the body must never route - /// through the Gateway-side `Viewer` registry the way `contact_lists` does for the CONNECTED - /// session's own guid, or ignore checks silently go empty for every Character not currently - /// registered on THIS Gateway process — the live defect this method replaces. - #[test] - fn ignored_guids_never_reads_the_viewer_registry() { - let source = crate::test_scan::code_of(include_str!("account.rs"), "pub fn ignored_guids("); - for needle in ["world_view", "viewer_of_owner", "Viewer"] { - assert!( - !source.contains(needle), - "ignored_guids must answer for a Character with no live Viewer on this Gateway; \ - found `{needle}`, which means it is reading `contact_lists`'s Viewer-scoped path \ - again" - ); - } - } } diff --git a/gateway/src/stdb/reads/guild.rs b/gateway/src/stdb/reads/guild.rs index 3671d2884..4f844a260 100644 --- a/gateway/src/stdb/reads/guild.rs +++ b/gateway/src/stdb/reads/guild.rs @@ -527,34 +527,3 @@ impl Coordinator { .find(&operation_id) } } - -#[cfg(test)] -mod tests { - /// The GM Gate's read must stay on the handle it is called on, the Home Shard. A realm-wide - /// union takes the first shard that holds the Character, default shard first, and a frozen copy - /// there would pass the Gate. - #[test] - fn the_gm_level_is_read_from_the_home_shard_only() { - let body = crate::test_scan::code_of(include_str!("guild.rs"), "fn home_gm_level("); - let flat: String = body.split_whitespace().collect(); - assert!( - flat.contains("self.0.coord().conn.db.game_character()"), - "{body}" - ); - for union in ["all_shards", "world_shards", "realm_core", "world_stores"] { - assert!( - !flat.contains(union), - "`home_gm_level` reads `{union}`:\n{body}" - ); - } - let forward = crate::test_scan::code_of( - include_str!("../../world/handlers/guild.rs"), - "fn guild_gm_level(&self, actor_guid: u64) -> Result {", - ); - let forward: String = forward.split_whitespace().collect(); - assert_eq!( - forward, - "{Ok(crate::stdb::Coordinator::home_gm_level(self,actor_guid))}" - ); - } -} diff --git a/gateway/src/stdb/reads/party.rs b/gateway/src/stdb/reads/party.rs index a63c86ecd..43abc48fe 100644 --- a/gateway/src/stdb/reads/party.rs +++ b/gateway/src/stdb/reads/party.rs @@ -438,66 +438,3 @@ impl Coordinator { }) } } - -#[cfg(test)] -mod member_stats_adapter_tests { - use crate::test_scan::code_of; - - fn flat(signature: &str) -> String { - code_of(include_str!("party.rs"), signature) - .split_whitespace() - .collect() - } - - /// Member Stats reads Realm Presence rather than running its own discovery — no second - /// live-entity/in-transit/absence decision left to duplicate `presence::of`'s. - #[test] - fn the_coordinator_converts_realm_presence_into_member_presence() { - let presence = flat("pub(crate) fn member_presence("); - assert!(presence.contains("crate::world::presence::of(self,guid)?")); - assert!(presence.contains("Whereabouts::InWorld{entity,shard_name,..}")); - assert!(presence.contains("self.with_member_shard_stats(&shard_name,guid,entity)")); - assert!(presence.contains("crate::world::MemberPresence::Live")); - assert!( - presence.contains("Whereabouts::InTransit)=>crate::world::MemberPresence::InTransit") - ); - assert!( - presence.contains("Whereabouts::Offline)|None=>crate::world::MemberPresence::Offline") - ); - } - - /// The aura and pet overlay must read from the SAME `ShardId` the live match came from, not a - /// fixed or default one — a cross-shard aura read would silently show nothing outside the - /// Gateway process that owns that shard's connection. - /// - /// A source scan, not a behavior test through a real `Coordinator`: `Coordinator` wraps the - /// spacetimedb-sdk `DbConnection`, which only a live SpacetimeDB node can populate, so this - /// method's actual database reads are outside what a unit test can drive. `crate::test_scan`'s - /// own module doc names that as the same untestable-by-mock shape `ReducerContext` is on the - /// Module side. This scan stays narrow: one substring per call it must make, not a copy of the - /// method body. - #[test] - fn the_shard_overlay_reads_auras_and_pet_from_the_matched_shard() { - let overlay = flat("fn with_member_shard_stats("); - assert!(overlay.contains("self.member_aura_slots(shard,guid)")); - assert!(overlay.contains("self.member_pet(shard,guid)")); - - let pet = flat("fn member_pet("); - assert!(pet.contains("self.member_aura_slots(shard,pet.guid)")); - } - - /// The pet read must stay a keyed lookup by the deterministic pet guid, never a table scan: - /// `game_world_entity().iter()` clones every cached row while holding the client cache's own - /// lock, and `member_pet` runs for every live group mate on every Relay tick, which can stall - /// the shard pump that lock also guards. Also a source scan; see the test above for why. - #[test] - fn member_pet_looks_up_the_deterministic_guid_instead_of_scanning() { - let pet = flat("fn member_pet("); - assert!(pet.contains("lyracore_shared::pet::pet_guid_for(owner_guid)")); - assert!(pet.contains(".guid().find(&pet_guid)")); - assert!( - !pet.contains("game_world_entity().iter()"), - "member_pet must not scan every live entity to find one pet" - ); - } -} diff --git a/gateway/src/stdb/reducers.rs b/gateway/src/stdb/reducers.rs index eecb72122..a287d53c1 100644 --- a/gateway/src/stdb/reducers.rs +++ b/gateway/src/stdb/reducers.rs @@ -3848,96 +3848,6 @@ impl Coordinator { } } -#[cfg(test)] -mod visibility_receipt_tests { - #[test] - fn quest_completion_waits_for_the_subscribed_coordinator_receipt() { - let source = include_str!("reducers.rs"); - let turn_in = crate::test_scan::code_of(source, "pub fn turn_in_quest("); - let visibility_pipe = crate::test_scan::code_of( - include_str!("connection.rs"), - "pub(crate) fn visibility_pipe(", - ); - - assert!( - turn_in.contains("self.0.visibility_pipe()"), - "a successful turn-in may authorize QUEST_COMPLETE only after the coordinator has \ - applied the reward transaction and queued its inventory relays" - ); - assert!( - !turn_in.contains("self.0.call_pipe()"), - "a reducer-only call pipe cannot receipt coordinator subscription visibility" - ); - assert!( - visibility_pipe.contains("self.coord()") - && !visibility_pipe.contains("self.call_pipe()"), - "the visibility pipe must be the connection that owns the relayed subscriptions" - ); - } -} - -#[cfg(test)] -mod realm_chat_routing_tests { - /// Party membership, and every later chat audience, is authoritative on Realm-core. A line sent - /// to the session's own Home Shard reads that Shard's mirror and is delivered by no Relay on a - /// sharded Realm. No Fake reaches the Coordinator, so the routing is pinned in source. - #[test] - fn realm_chat_runs_on_the_realm_core_handle() { - let body = crate::test_scan::code_of(include_str!("reducers.rs"), "pub fn realm_chat("); - let body: String = body.split_whitespace().collect(); - assert!( - body.contains("letrealm=self.realm_core()?;") - && body.contains("realm.0.call_pipe().conn.reducers,\"realm_chat\",") - && body.contains("realm_chat_then(realm.session_actor(speaker_guid),request)"), - "`Coordinator::realm_chat` no longer calls the reducer on the Realm-core handle. \ - Body was:\n{body}" - ); - assert!( - !body.contains("self.0.call_pipe()"), - "`Coordinator::realm_chat` must not call the session's own Home Shard" - ); - } - - /// A whisper is a set of Realm Chat Lines, so it runs on Realm-core like `realm_chat`. Pinned - /// in source for the same reason. - #[test] - fn realm_whispers_run_on_the_realm_core_handle() { - let body = crate::test_scan::code_of(include_str!("reducers.rs"), "pub fn realm_whisper("); - let body: String = body.split_whitespace().collect(); - assert!( - body.contains("letrealm=self.realm_core()?;") - && body.contains("realm.0.call_pipe().conn.reducers,\"realm_whisper\",") - && body.contains("realm_whisper_then(realm.session_actor(speaker_guid),request)"), - "`Coordinator::realm_whisper` no longer calls the reducer on the Realm-core handle. \ - Body was:\n{body}" - ); - assert!( - !body.contains("self.0.call_pipe()"), - "`Coordinator::realm_whisper` must not call the session's own Home Shard" - ); - } - - /// Chat Channels live only on Realm-core. An op sent to the session's own Home Shard would - /// create a second, shard-local copy of the channel. Pinned in source for the same reason. - #[test] - fn channel_ops_run_on_the_realm_core_handle() { - let body = crate::test_scan::code_of(include_str!("reducers.rs"), "pub fn channel_op("); - let body: String = body.split_whitespace().collect(); - assert!( - body.contains("letrealm=self.realm_core()?;") - && body.contains("realm.0.call_pipe().conn.reducers,\"realm_channel_op\",") - && body - .contains("realm_channel_op_then(realm.session_actor(actor_guid),op,request)"), - "`Coordinator::channel_op` no longer calls the reducer on the Realm-core handle. \ - Body was:\n{body}" - ); - assert!( - !body.contains("self.0.call_pipe()"), - "`Coordinator::channel_op` must not call the session's own Home Shard" - ); - } -} - #[cfg(test)] mod taxi_reply_tests { use super::*; @@ -3996,23 +3906,6 @@ mod taxi_reply_tests { lyracore_shared::constants::taxi_protocol::REPLY_STATUS, )); } - - #[test] - fn reply_wait_uses_the_unique_request_id_accessor() { - let source = include_str!("reducers.rs"); - assert!(source.contains(".request_id()\n .find(&request_id)")); - assert!(!source.contains(".character_guid()\n .find(&character_guid)")); - let wait = source - .split("fn await_taxi_reply(") - .nth(1) - .and_then(|tail| tail.split("pub fn taxi_node_status(").next()) - .expect("taxi reply wait body"); - let observes = wait.find("taxi_reply_matches(").expect("validated reply"); - let acknowledges = wait - .find("gw_ack_taxi_reply_then(self.session_actor(character_guid), request_id)") - .expect("reply acknowledgement"); - assert!(observes < acknowledges); - } } /// Wait up to two seconds for a row a value-flow reducer just committed to reach this handle's @@ -4998,28 +4891,6 @@ mod auction_reducer_tests { )); } - #[test] - fn refused_listing_refund_commits_on_realm_core_before_the_home_hold_is_deleted() { - let drive = - crate::test_scan::code_of(include_str!("reducers.rs"), "fn complete_listing_hold("); - let refund = "realm.auction_refund_listing(hold)?;"; - let release = "self.auction_release_listing_hold(hold)?;"; - let refund_at = drive - .find(refund) - .expect("the Realm-core handle must commit the refused listing Mail"); - let release_at = drive - .find(release) - .expect("the Home Shard must delete the Hold after that commit"); - assert!( - refund_at < release_at, - "a source-Hold delete before Realm-core Mail commit loses the only listing value" - ); - assert!( - !drive.contains("self.auction_refund_listing(hold)?;"), - "the Home Shard does not own Mail in a sharded realm" - ); - } - #[test] fn refused_listing_binding_matches_the_generated_commit_listing_shape() { let expected = include_str!("bindings/realm_auction_commit_listing_reducer.rs") @@ -5144,29 +5015,6 @@ mod auction_reducer_tests { )); } - /// Per-request paths find a Character's Holds, receipts and Auctions through the auction - /// index, never by scanning a cache table, which keeps every finished Hold and receipt. - #[test] - fn auction_lookups_never_scan_a_cache_table() { - for signature in [ - "fn indexed_rows(", - "fn unfinished_auction_holds(", - "fn listing_holds(", - "fn listing_receipts(", - "fn has_auction(", - "fn matching_unfinished_bid_hold(", - "fn matching_auction_hold(", - "fn matching_active_auction_receipt(", - "fn character_has_auction_value(", - "pub(crate) fn cancel_auction(", - "pub(crate) fn resume_auction_holds(", - "fn complete_listing_hold(", - ] { - let body = crate::test_scan::code_of(include_str!("reducers.rs"), signature); - assert!(!body.contains(".iter()"), "{signature} scans a cache table"); - } - } - #[test] fn normalized_buyout_price_drives_the_exact_success_command_value() { let hold = AuctionBidHold { diff --git a/gateway/src/stdb/subscriptions.rs b/gateway/src/stdb/subscriptions.rs index f8fa9e9e1..b087e61c9 100644 --- a/gateway/src/stdb/subscriptions.rs +++ b/gateway/src/stdb/subscriptions.rs @@ -7111,360 +7111,6 @@ mod tests { assert!(!instance_relay_gate(7, None)); } - /// Remove line and nested block comments while preserving ordinary string literals. - /// - /// This is deliberately limited to the Rust source spans scanned in this module. The scans need - /// quoted labels, so comment markers inside a quoted label remain part of the source. - fn without_comments(text: &str) -> String { - let mut output = String::with_capacity(text.len()); - let mut chars = text.char_indices().peekable(); - let (mut in_string, mut in_line_comment, mut block_depth) = (false, false, 0usize); - - while let Some((index, character)) = chars.next() { - if in_line_comment { - if character == '\n' { - in_line_comment = false; - output.push(character); - } - continue; - } - - if block_depth > 0 { - if character == '/' && text[index..].starts_with("/*") { - block_depth += 1; - chars.next(); - } else if character == '*' && text[index..].starts_with("*/") { - block_depth -= 1; - chars.next(); - } else if character == '\n' { - output.push(character); - } - continue; - } - - if in_string { - output.push(character); - if character == '\\' { - if let Some((_, escaped)) = chars.next() { - output.push(escaped); - } - } else if character == '"' { - in_string = false; - } - continue; - } - - match character { - '"' => { - in_string = true; - output.push(character); - } - '/' if text[index..].starts_with("//") => { - in_line_comment = true; - output.push(' '); - } - '/' if text[index..].starts_with("/*") => { - block_depth = 1; - output.push(' '); - chars.next(); - } - _ => output.push(character), - } - } - - output - } - - /// The non-test half of this file as the source scans below may read it: comments stripped and - /// every whitespace run collapsed to one space. - /// - /// Both steps are mutation findings, not tidiness. Counting matches in the raw text made every - /// scan below satisfiable by a COMMENT quoting the pattern while the real call was deleted — - /// live-mutated green, with both gates permanently shut (no discovery toast, no inn-crossing - /// relay, ever). Collapsing whitespace is the other half: it stops a rustfmt line-split from - /// failing a scan that nothing behavioural broke, and it lets a scan pin a whole STATEMENT - /// rather than a bare identifier — which is what makes "call it and throw the packet away" - /// visible. - fn scanned_source() -> String { - let src = include_str!("subscriptions.rs"); - let body = src - .split("mod tests {") - .next() - .expect("the non-test half of this file"); - let decommented = without_comments(body); - decommented.split_whitespace().collect::>().join(" ") - } - - /// The span of ONE top-level `fn NAME(` in a DIFFERENT file of this module tree — the - /// shared-connection model moved the AOI wiring out of this file, and a tripwire that can only - /// see this file would have gone - /// quiet rather than failing. Brace-matched, so it works on `impl`-nested layouts too. Returns - /// the RAW (not comment-stripped) text — callers that need the mutation-hardened decommenting - /// run it through [`decommented`] themselves, same two-step `scanned_source` already uses. - fn top_level_fn_body_of(file: &str, name: &str) -> &'static str { - let src: &'static str = match file { - "world_view.rs" => include_str!("world_view.rs"), - "connection.rs" => include_str!("connection.rs"), - other => panic!("no scanner registered for `{other}`"), - }; - let start = src - .find(&format!("fn {name}(")) - .unwrap_or_else(|| panic!("`fn {name}(` not found in {file}")); - let open = src[start..].find('{').expect("a body") + start; - let mut depth = 0usize; - for (i, c) in src[open..].char_indices() { - match c { - '{' => depth += 1, - '}' => { - depth -= 1; - if depth == 0 { - return &src[open..open + i + 1]; - } - } - _ => {} - } - } - panic!("unbalanced braces scanning `{name}` in {file}"); - } - - /// Comment-stripped, whitespace-collapsed form of an arbitrary source span — the same two-step - /// mutation-hardening [`scanned_source`] applies to the whole file (strip comments - /// FIRST, so a scan can't be satisfied by a comment quoting the pattern; collapse whitespace - /// SECOND, so a rustfmt line-split can't fail a scan that pins a whole statement). - /// - /// The last step drops what a broken-out call leaves behind — the space after `(`, the trailing - /// comma before `)` — so a needle reads as one written call whichever way rustfmt wrapped it. - fn decommented(text: &str) -> String { - let stripped = without_comments(text); - let one_line = stripped.split_whitespace().collect::>().join(" "); - one_line - .replace("( ", "(") - .replace(", )", ")") - .replace(" )", ")") - } - - #[test] - fn source_scans_ignore_block_commented_code() { - let source = r#" - wire_insert_live( - db.live_table(), - "live.label", - &view, - |v, row| relay(v, row), - ); - /* - wire_insert_live(db.decoy_table(), "decoy.label", &view, relay); - /* wire_insert_live(db.nested_decoy(), "nested.label", &view, relay); */ - */ - let label = "/* quoted label */"; - "#; - - let scanned = decommented(source); - let compact: String = scanned.chars().filter(|c| !c.is_whitespace()).collect(); - - assert!(compact.contains( - "wire_insert_live(db.live_table(),\"live.label\",&view,|v,row|relay(v,row));" - )); - assert!(!scanned.contains("decoy_table")); - assert!(!scanned.contains("nested_decoy")); - assert!(scanned.contains("\"/* quoted label */\"")); - } - - /// World entry seeds the viewer's ignore AND friend sets from its own contact rows on this - /// Home Shard, read from the Shard's `ContactIndex`, NOT through `contact_lists`, which - /// reads the registered Viewer this call is about to create, and would see nothing yet. - /// No Fake reaches this coordinator read, so the seed is pinned in source. Without it the sets - /// stay empty until the first live contact change: an ignorable Realm Chat Line reaches a - /// Character who ignores its speaker, and a friend's login/logout notice never reaches this - /// viewer. - #[test] - fn the_viewer_is_seeded_with_its_own_ignore_and_friend_sets() { - let body = crate::test_scan::code_of( - include_str!("subscriptions.rs"), - "pub fn subscribe_player_events(", - ); - let compact: String = decommented(&body) - .chars() - .filter(|c| !c.is_whitespace()) - .collect(); - assert!( - compact.contains("contacts.ignored_by(self_guid)") - && compact.contains("contacts.friends_of(self_guid)"), - "world entry no longer reads its own contact rows for the ignore/friend seed" - ); - assert!( - compact.contains("ignored:Mutex::new(ignored),friends:Mutex::new(friends),team,"), - "the viewer is no longer constructed with the seeded ignore/friend sets and team" - ); - } - - /// The call-site tripwire for the shared-view `Viewer`'s construction. - /// - /// The session's `Viewer` must hold the same dedup/gate state prepared during world entry. - /// Hand it a fresh instance and the exactly-once CREATE or spirit-healer gate silently splits. - #[test] - fn the_viewer_is_registered_with_the_shared_dedup_sets_and_gates() { - let code = scanned_source(); - for field in [ - "bound_identity: self_identity,", - "created: created.clone(),", - "gates: viewer_gates.clone(),", - "skill_slots: skill_slots.clone(),", - ] { - assert!( - code.contains(field), - "the shared-view `Viewer` is no longer constructed with `{field}` — world entry and \ - shared writer jobs now use different relay state" - ); - } - assert!( - code.contains("view.add_viewer( self, viewer.clone(), CellKey::of_position(login_map, login_instance, login_x, login_y), )"), - "the session is no longer registered with the shared AOI view at its LOGIN cell — \ - without the registration the client sees an empty world; with the wrong anchor it sees \ - somebody else's neighbourhood" - ); - } - - /// Tripwire for the realm-core PRIVATE tier (#22 → #483): the cross-shard group and chat - /// twins are armed ONCE per realm-core connection (`arm_realm_private`), gated on realm-core - /// being a DISTINCT database. Without the gate a single-database gateway registers a SECOND - /// callback on tables `arm_shard` already watches and every private packet is delivered - /// twice; without the arming a cross-shard Realm Chat Line is written on realm-core and - /// delivered to nobody. No fake in this tree can reach either (a callback on another - /// database's live coordinator connection), so the wiring is pinned in source, - /// comment-stripped. - #[test] - fn the_realm_private_tier_is_armed_once_gated_on_a_distinct_database() { - let body = decommented(top_level_fn_body_of( - "connection.rs", - "arm_shared_world_view", - )); - assert!( - body.contains("if let Ok(realm) = self.realm_core() {"), - "arming no longer resolves the realm-core handle — pointed anywhere else it hears \ - nothing at all" - ); - assert!( - body.contains("if !shards.iter().any(|s| s.shard_name() == realm.shard_name()) {"), - "the realm-core private tier lost its DISTINCT-DATABASE guard. On a single-database \ - gateway (or with `LYRACORE_REALM_CORE` naming a world shard) `arm_shard` already \ - watches these tables, so arming realm-core too delivers every invite dialog and \ - Realm Chat Line twice." - ); - assert!( - body.contains( - "super::world_view::arm_realm_private(view.clone(), realm.clone(), world.clone());" - ), - "the realm-core private tier is never armed: a cross-shard Realm Chat Line is \ - written on realm-core and delivered to nobody" - ); - assert_eq!( - body.matches("arm_realm_private(").count(), - 2, - "the realm-core private tier must be armed exactly twice in source: once at connect \ - and once inside the reconnect re-arm hook — a module republish closes the websocket, \ - and without the hook the relay dies silently at the first republish" - ); - } - - /// The other half: `arm_realm_private` must register its tables through the same - /// recipient-keyed dispatchers as `arm_shard`'s private tier. The recipient filter is - /// structural there (`session_of_owner(row.recipient_guid)` + `private_recipient_audience`) - /// — these reads ride the OWNER TOKEN, which bypasses RLS, so a dispatcher that fanned to - /// viewers instead would hand every player's private lines to every session. - #[test] - fn the_realm_private_relays_ride_the_recipient_keyed_dispatchers() { - let body = decommented(top_level_fn_body_of("world_view.rs", "arm_realm_private")); - let compact: String = body.chars().filter(|c| !c.is_whitespace()).collect(); - assert!( - compact.contains("wire_group_events(db,\"realm.game_group_event.insert\",&view,coord.clone(),GroupEventSource::RealmCore);"), - "arm_realm_private no longer relays realm-core group events through \ - `group_event_appeared` (which also carries the QUEST_SHARE detail JOIN through a \ - WORLD handle — realm-core's cache has no quest catalogue)" - ); - // Realm Chat Lines need BOTH registrations: realm-core's for a sharded Realm, the shard's - // own for a Realm whose Realm-core is that database. Only Realm-core writes the table, so - // no line is delivered twice. - assert!( - compact.contains("wire_insert_live(db.game_realm_chat_event(),\"realm.game_realm_chat_event.insert\",&view,|v,row|realm_chat_appeared(v,row));"), - "arm_realm_private no longer relays Realm Chat Lines, so every party line and \ - whisper on a sharded Realm is written and heard by nobody" - ); - let shard = decommented(top_level_fn_body_of( - "world_view.rs", - "register_shard_callbacks", - )); - let shard: String = shard.chars().filter(|c| !c.is_whitespace()).collect(); - assert!( - shard.contains("wire_insert_live(db.game_realm_chat_event(),\"game_realm_chat_event.insert\",&view,|v,row|realm_chat_appeared(v,row));"), - "arm_shard no longer relays Realm Chat Lines, so every party line on an unsharded \ - Realm is written and heard by nobody" - ); - let relay = decommented(top_level_fn_body_of("world_view.rs", "realm_chat_appeared")); - assert!( - relay.contains("session_of_owner(recipient)") - && relay.contains("private_recipient_audience(recipient, viewer.self_guid)"), - "realm_chat_appeared is no longer recipient-keyed. On an owner-token read every \ - session would receive every party's chat" - ); - assert!( - compact.contains("wire_insert_live(db.game_mail_arrival(),\"realm.game_mail_arrival.insert\",&view,|v,row|mail_arrived(v,row));"), - "arm_realm_private no longer relays Mail Arrivals through `mail_arrived`. Mail lives \ - on realm-core, so no recipient on a World Shard would hear of a new Mail" - ); - // The dispatchers themselves stay recipient-keyed: each body must resolve the recipient's - // session FIRST and re-assert the audience predicate. - for dispatcher in ["group_event_appeared", "mail_arrived"] { - let body = decommented(top_level_fn_body_of("world_view.rs", dispatcher)); - assert!( - body.contains("session_of_owner(row.recipient_guid)") - && body.contains( - "private_recipient_audience(row.recipient_guid, viewer.self_guid)" - ), - "{dispatcher} is no longer recipient-keyed — on an owner-token read that is a \ - privacy leak: every session would receive every player's private rows" - ); - } - } - - /// `game_auction_notice` rides the same private tier: armed once on each shard connection - /// (`register_shard_callbacks`) and once more on the realm-core connection (`arm_realm_private`) - /// for a cross-shard bidder or seller, both through `auction_notice_appeared`, which is itself - /// recipient-keyed the same way `mail_arrived` is above. - #[test] - fn the_auction_notice_relay_is_armed_on_both_connections_and_recipient_keyed() { - let shard = decommented(top_level_fn_body_of( - "world_view.rs", - "register_shard_callbacks", - )); - let shard: String = shard.chars().filter(|c| !c.is_whitespace()).collect(); - assert!( - shard.contains("wire_insert_live(db.game_auction_notice(),\"game_auction_notice.insert\",&view,|v,row|auction_notice_appeared(v,row));"), - "register_shard_callbacks no longer relays Auction Notices through \ - `auction_notice_appeared`" - ); - - let realm = decommented(top_level_fn_body_of("world_view.rs", "arm_realm_private")); - let realm: String = realm.chars().filter(|c| !c.is_whitespace()).collect(); - assert!( - realm.contains("wire_insert_live(db.game_auction_notice(),\"realm.game_auction_notice.insert\",&view,|v,row|auction_notice_appeared(v,row));"), - "arm_realm_private no longer relays realm-core Auction Notices, so a cross-shard \ - bidder or seller never hears their outbid, won, sold, expired or new-bid packet" - ); - - let relay = decommented(top_level_fn_body_of( - "world_view.rs", - "auction_notice_appeared", - )); - assert!( - relay.contains("session_of_owner(row.recipient_guid)") - && relay - .contains("private_recipient_audience(row.recipient_guid, viewer.self_guid)"), - "auction_notice_appeared is no longer recipient-keyed — on an owner-token read that \ - is a privacy leak: every session would receive every auction's notices" - ); - } - // ── Peer-motion relay: the CALL SITE, not just the codec helper ────────── // // §8 of the agent playbook: the dominant failure here is a test that pins an extracted helper @@ -7790,31 +7436,6 @@ mod tests { ); } - #[test] - fn peer_create_dispatch_admits_once_before_appending_resident_movement() { - let body = crate::test_scan::code_of( - include_str!("subscriptions.rs"), - "pub(crate) fn offer_peer_create_for(", - ); - let body: String = body.split_whitespace().collect(); - let gate = body.find("if!peer_create_gate(").unwrap(); - let dedup = body - .find("if!viewer.created.lock().unwrap().insert(row.guid)") - .unwrap(); - let create = body.find("build_peer_create(").unwrap(); - let replay = body.find("append_resident_creature_after_create(").unwrap(); - - assert!(gate < dedup && dedup < create && create < replay); - assert!(body[gate..dedup].contains("{returnVec::new();}")); - assert!(body[dedup..create].contains("{returnVec::new();}")); - assert_eq!( - body.matches("append_resident_creature_after_create(") - .count(), - 1, - "the admitted CREATE path must append resident movement exactly once" - ); - } - /// A running-forward movement block plus the bytes the module would have stored for it. fn moving_forward() -> (wow_world_messages::vanilla::MovementInfo, Vec) { let info = wow_world_messages::vanilla::MovementInfo { @@ -8407,114 +8028,6 @@ mod tests { "a send that queued nothing must not leave the depth raised" ); } - - /// The relay's own WIRING, which no unit test can reach: `relay_entity_motion` only ever runs - /// because both halves of the `game_entity_motion` subscription call it (insert = a mover's first - /// heartbeat in this box, update = every one after). Deleting either closure leaves every test - /// above green and freezes peers — the stuck-relay shape, and the "grep every subscriber when a - /// relay changes shape" hazard the peer-motion relay work opens with. A scan (not a mock) for the - /// reason the other tripwires in this file are: the callbacks are registered on a live - /// `DbConnection`. - /// Both halves of the motion relay, re-pinned for the shared-connection model: `game_entity_motion` is - /// UPSERTED per mover per heartbeat, so `on_insert` is a mover's first heartbeat in the world and - /// `on_update` is essentially all peer movement. Dropping either registration is silent — peers - /// freeze after their first step, or never move at all. Same for the creature-leg twin. - #[test] - fn both_halves_of_the_motion_and_spline_relays_are_registered_286() { - let arm: String = decommented(top_level_fn_body_of( - "world_view.rs", - "register_shard_callbacks", - )) - .chars() - .filter(|c| !c.is_whitespace()) - .collect(); - // #490 factored every registration through `wire_insert`/`wire_update` (world_view.rs), - // so the literal `.on_insert(`/`.on_update(` chain off the table handle is gone from - // `arm_shard`'s own body — what's left to scan for is the (helper, table, label) triple - // that proves the callback is still wired, not skipped. - for (helper, table, label, what) in [ - ( - "wire_insert", - "game_entity_motion", - "game_entity_motion.insert", - "a mover's FIRST heartbeat", - ), - ( - "wire_update", - "game_entity_motion", - "game_entity_motion.update", - "every heartbeat after the first — i.e. all peer movement", - ), - ( - "wire_insert", - "game_creature_spline", - "game_creature_spline.insert", - "a creature's first leg", - ), - ( - "wire_update", - "game_creature_spline", - "game_creature_spline.update", - "every leg after the first", - ), - ] { - assert!( - arm.contains(&format!("{helper}(db.{table}()")) - && arm.contains(&format!("\"{label}\"")), - "the {table} {label} half is no longer registered on the shared coordinator \ - dispatch — {what} is silently dropped for every session" - ); - } - // And the dispatch routes them through the cell index rather than broadcasting. - let m = decommented(top_level_fn_body_of("world_view.rs", "motion")); - assert!( - m.contains("view.cell_audience(shard, Some(key), BOX_HALF_SPAN, &[])"), - "peer motion no longer asks the cell index who can see the mover — either every session \ - gets every mover (the fan-out this issue removed) or none do" - ); - } - - /// And the instrument's wiring: the counters are only ever read by the 10-second task in - /// `world::run`. Without this scan, reverting that task to the old hand-formatted line — no - /// delivery ratio, no dropped count — leaves the whole suite green, which is precisely how the - /// 63 % under-delivery went unlogged in the first place. - #[test] - fn the_10s_task_prints_the_delivery_instrument_287() { - let body = crate::test_scan::code_of(include_str!("../world/mod.rs"), "pub async fn run("); - assert!( - body.contains("crate::stdb::subscriptions::MOTION_DROPPED.load(Relaxed)") - && body.contains("crate::stdb::subscriptions::motionstat_line("), - "the MOTIONSTAT task no longer reads MOTION_DROPPED and formats through \ - `motionstat_line` — the delivery ratio and dropped count are gone from the log, and a \ - degrading relay is once again silent. Body was:\n{body}" - ); - } - - /// And the fan-out signal's WIRING (task B1). `fanout_health_step` is pure and fully tested above, - /// which is exactly the trap playbook §8 names: the decision can be perfect while the only thing - /// that CALLS it — the 10-second task in `world::run`, unreachable from a unit test — quietly - /// disappears, leaving the suite green and the under-delivery silent again. A scan, because the - /// task body is a `tokio::spawn` inside `run`; asserted on both the step call and the `log::warn!` - /// that is the entire deliverable (computing the verdict and not printing it would be the same - /// "reported the call, not the effect" defect class). - #[test] - fn the_10s_task_warns_on_a_fanout_collapse_b1() { - let body = crate::test_scan::code_of(include_str!("../world/mod.rs"), "pub async fn run("); - assert!( - body.contains( - "crate::stdb::subscriptions::fanout_health_step(fan, fanout, submitted_delta)" - ), - "the 10s task no longer runs the fan-out collapse check — peer movement can degrade by \ - 40% and the log will say nothing but a MOTIONSTAT line nobody can calibrate. Body \ - was:\n{body}" - ); - assert!( - body.contains("if let Some(low_windows) = fanout_warn {") - && body.contains("log::warn!"), - "the fan-out verdict is computed but no longer WARNED — the whole point is that it says \ - something out loud. Body was:\n{body}" - ); - } } #[cfg(test)] diff --git a/gateway/src/stdb/world_store.rs b/gateway/src/stdb/world_store.rs index f588cd274..d14e6a527 100644 --- a/gateway/src/stdb/world_store.rs +++ b/gateway/src/stdb/world_store.rs @@ -114,8 +114,7 @@ impl WorldStore for Coordinator { /// **This, not `home_shard`, is the production world-entry resolver.** `world::route_home` /// calls `settle_home_shard`, and this impl OVERRIDES it — so any routing rule that is /// applied only in `home_shard` is dead code in production while every suite stays green (every - /// mock takes the trait default, which forwards to `home_shard`). That trap is why the - /// tripwire at the bottom of this file scans THIS body. + /// mock takes the trait default, which forwards to `home_shard`). /// /// The HOLDER lookup below is `realm_core::locate_home_shard` — the realm-core /// index consulted FIRST, the shard scan only on a miss, self-heal write-back on either path — @@ -1658,10 +1657,9 @@ impl WorldStore for Coordinator { /// `Coordinator`'s like-named INHERENT method — Rust resolves inherent methods first, so these are /// forwards, not recursion, exactly like the `WorldStore` impl above. /// -/// This block is the ONE layer `realm_core.rs`'s fake substitutes for wholesale, so it is pinned by -/// exact-shape equality in `realm_core::tests::the_coordinator_forwards_are_views_not_logic`. Keep -/// it a block of forwards; any logic that grows here is untested by construction. `has_escrow` -/// narrows `Option` to a bool. See that test's doc for why this remains safe. +/// This block is the ONE layer `realm_core.rs`'s fake substitutes for wholesale. Keep it a block of +/// forwards; any logic that grows here is untested by construction. `has_escrow` narrows +/// `Option` to a bool. impl crate::realm_core::RealmDb for Coordinator { fn shard_name(&self) -> &str { self.shard_name() @@ -1814,398 +1812,7 @@ impl crate::realm_core::RealmDb for Coordinator { } #[cfg(test)] -mod routing_call_site_tests { - /// One method's body in this file, comments stripped. The routing decisions in this impl run - /// against a live SpacetimeDB node and NOTHING ELSE, so no fake can reach - /// them: the pure resolvers in `crate::config` are pinned by their own tests, and the trait-level - /// behavior is pinned by the fakes in `world::tests` — but *whether the Coordinator's own - /// override still calls them* is exactly the mutation that stays green in both. A source scan is - /// what the module tier already does for its un-runnable call sites (`transfer.rs`'s fence - /// tripwires), so the same instrument is used here. Comments are stripped because a scanner that - /// matches prose pins prose (the `code_of` lesson) — the doc comment above each call site names - /// every symbol asserted below. - fn code_of(method: &str) -> String { - let src = include_str!("world_store.rs"); - let needle = format!("fn {method}("); - // The scan takes the FIRST match, so a second definition of the same name anywhere earlier - // in this file — an inherent `impl Coordinator` block carrying the blessed text, say — - // silently becomes the thing every assertion below is measured against while the real - // override is free to be anything. That defeat was demonstrated during this PR's - // adversarial review: an eight-line decoy above `impl WorldStore for Coordinator` left - // `publish_shard_index` a bare `Ok(())` in production with all 403 tests green. A source - // scan is only worth having if it cannot be aimed somewhere else, so: exactly one. - let hits = src - .lines() - .filter(|l| !l.trim_start().starts_with("//") && l.contains(needle.as_str())) - .count(); - assert_eq!( - hits, 1, - "`{needle}` appears {hits} times in world_store.rs — a source scan that takes the first \ - match cannot tell which one production calls. Either there is a decoy definition \ - shadowing the scanned call site, or a legitimate second one that this tripwire must be \ - re-aimed at explicitly." - ); - let body = src - .split_once(needle.as_str()) - .unwrap_or_else(|| panic!("`impl WorldStore for Coordinator` still defines {method}")) - .1; - let body = body.split_once("\n fn ").map_or(body, |(b, _)| b); - body.lines() - .map(strip_line_comment) - .map(str::trim_end) - .filter(|l| !l.is_empty()) - .collect::>() - .join("\n") - } - - /// Strip a Rust line comment from `line`, respecting simple double-quoted string literals so a - /// `//` inside one is never mistaken for a comment start. Also closes the trailing-comment - /// defeat `code_of` above used to be open to (`.filter(|l| - /// !l.trim_start().starts_with("//"))` only drops a comment that is the FIRST thing on its - /// line, so a needle surviving in a trailing comment on an otherwise-live line still satisfied - /// a `.contains()` scan). Module-crate copy: `module/src/test_scan.rs`, which this cannot import - /// across the crate boundary — kept in sync by hand, and both are exercised by their own tests. - fn strip_line_comment(line: &str) -> &str { - let bytes = line.as_bytes(); - let mut in_string = false; - let mut i = 0; - while i < bytes.len() { - match bytes[i] { - b'\\' if in_string => i += 1, - b'"' => in_string = !in_string, - b'/' if !in_string && bytes.get(i + 1) == Some(&b'/') => return &line[..i], - _ => {} - } - i += 1; - } - line - } - - /// `Coordinator::realm_core` — the AUTH ROUTER, and the one edit that reverts the whole - /// realm-core auth split. - /// - /// Found surviving by an adversarial review. `impl RealmDb for Coordinator` is - /// pinned below, and every `realm_core.rs` body is executed against `fake::Handle` — but both - /// stop at the forward. The inherent method the forward calls is one level lower, and replacing - /// its body with `Ok(self.clone())` left ALL 403 gateway tests green while: - /// - /// * the SRP6 challenge is answered from the world shard's write-through `game_account` cache, - /// so a password rotation or a ban applied on realm-core stops being enforced (M1's live - /// consequence, reached one call below where M1 was pinned); - /// * the world handshake reads K from the world shard's session cache (M5, ditto); - /// * `establish_session` writes only the world shard, so the realm-wide session row — the whole - /// point of a shared session table — is never written; - /// * fail-closed is gone: a dead realm-core no longer refuses anything, it is simply not asked. - /// - /// That is the exact regression the realm-core split was built to prevent, so it does not get - /// to be the one line nothing watches. It cannot be executed (it reads - /// a live websocket's liveness), so it gets the same instrument as the forwards: exact shape. - #[test] - fn the_auth_database_router_is_still_a_router_and_not_a_passthrough() { - let src = include_str!("connection.rs"); - let needle = "pub(crate) fn realm_core(&self)"; - let hits = src - .lines() - .filter(|l| !l.trim_start().starts_with("//") && l.contains(needle)) - .count(); - assert_eq!( - hits, 1, - "`{needle}` is defined {hits} times in connection.rs — a first-match \ - source scan cannot tell which one the auth paths call" - ); - let body = &src[src.find(needle).expect("checked above")..]; - let end = body - .find("\n }\n") - .expect("unterminated `realm_core` body"); - let shape = body[..end] - .lines() - .filter(|l| !l.trim_start().starts_with("//")) - .collect::>() - .join(" ") - .split_whitespace() - .collect::>() - .join(" "); - let want = "pub(crate) fn realm_core(&self) -> Result { \ - let db = self .1 .map .auth_db(|d| { \ - self.1.conns.get(d).is_some_and(|inner| { \ - let live = inner.coord(); \ - live.is_healthy() }) }) \ - .map_err(|db| { anyhow!( \ - \"realm-core database {db} is not connected — refusing to authenticate against \\ \ - the world database's stale auth cache\" ) })?; \ - let inner = self .1 .conns .get(db) \ - .ok_or_else(|| anyhow!(\"auth database {db} missing from the coordinator set\"))?; \ - Ok(Coordinator(inner.clone(), self.1.clone(), self.2.clone()))" - .split_whitespace() - .collect::>() - .join(" "); - assert_eq!( - shape, want, - "`Coordinator::realm_core` is no longer the auth ROUTER it is documented to be. It must \ - resolve the database through `ShardMap::auth_db` with a LIVE connection predicate and \ - hand back a handle on THAT database — never `self`, and never an `Ok` on a realm-core \ - that is configured but not connected. Everything that authenticates goes through this \ - one function and nothing else can observe it; a deliberate change must be re-blessed \ - here with the same care." - ); - } - - /// The two `WorldStore` methods whose whole body is a delegation into `crate::realm_core` — - /// pinned by EXACT SHAPE, the same instrument `module/src/transfer/mod.rs` uses for `CtxShard`. - /// - /// This is the seam's own blind spot, and it was measured, not assumed. `realm_core.rs`'s tests - /// substitute `fake::Handle` for `Coordinator`, and `world::tests`' mocks substitute - /// `InMemoryStore` for it — so BOTH suites are blind to this adapter, and two mutations proved - /// it by staying green across all 402 tests: - /// - /// | edit | live consequence | - /// |---|---| - /// | `publish_shard_index` → `Ok(())` | every completed transfer stops telling realm-core where the character went — the shard index silently stops being written | - /// | `lookup_session` → read the world DB directly | the world handshake authenticates off the world shard's session cache: realm-core's entire point, reverted | - /// - /// Equality rather than `contains`, because a `contains` scan is defeated by leaving its own - /// text in a dead branch — which this repo has had done to it repeatedly. A deliberate change - /// here must be re-blessed with the same care. - #[test] - fn the_realm_core_delegations_are_exactly_delegations() { - for (method, want) in [ - ( - "lookup_session", - "&self, account_name: &str) -> Result> { \ - crate::realm_core::lookup_session(self, account_name) }", - ), - ( - "publish_shard_index", - "&self, character_guid: u64, map_id: u32, instance_id: u64, ) -> Result<()> { \ - crate::realm_core::publish_shard_index(self, character_guid, map_id, instance_id) }", - ), - ] { - let got = code_of(method) - .split_whitespace() - .collect::>() - .join(" "); - let want = want.split_whitespace().collect::>().join(" "); - assert_eq!( - got.trim_end_matches('}').trim_end(), - want.trim_end_matches('}').trim_end(), - "`impl WorldStore for Coordinator::{method}` is no longer the bare delegation into \ - `crate::realm_core` that both test suites assume it is. `realm_core.rs`'s tests run \ - the shared body with a FAKE substituted for this handle and `world::tests`' mocks \ - override this method entirely, so nothing either suite asserts covers an edit here \ - — and each of these is one line whose damage is total and silent." - ); - } - } - - /// `delete_character`'s routing composition. `realm_core::resolve_delete_shard` is tested - /// BEHAVIOURALLY (`realm_core.rs`, via `fake::Handle`) for the resolution and refusal decisions - /// themselves, but — like `settle_home_shard` above — nothing can exercise *whether Coordinator's - /// override actually dispatches through that answer* without a live node: `world::tests`' mocks - /// implement `WorldStore` directly and never take this override. Pinned by EXACT SHAPE rather - /// than `the_realm_core_delegations_are_exactly_delegations`'s bare-forward instrument just - /// above, because this body is three branches, not one line — a `contains` scan of any single - /// branch is defeated by a decoy left in a dead one (demonstrated live in this repo), and - /// equality is what actually pins the composition: that delete runs on the RESOLVED owner (not - /// unconditionally on `self`, which was the bug), and that a refusal (`Err`) reports - /// `Failed` rather than falling through to delete anyway (which would race a resumed transfer). - #[test] - fn delete_character_dispatches_through_the_resolved_shard_not_always_self() { - let got = code_of("delete_character") - .split_whitespace() - .collect::>() - .join(" "); - let want = - "&self, account_id: u64, character_guid: u64, ) -> Result { \ - match crate::realm_core::resolve_delete_shard(self, character_guid) { \ - Ok(Some(owner)) => owner.delete_character(account_id, character_guid), \ - Ok(None) => self.delete_character(account_id, character_guid), \ - Err(e) => { \ - log::warn!(\"world: {e:#}\"); \ - Ok(codec::CharDeleteOutcome::Failed) \ - } \ - } \ - }" - .split_whitespace() - .collect::>() - .join(" "); - assert_eq!( - got.trim_end_matches('}').trim_end(), - want.trim_end_matches('}').trim_end(), - "`impl WorldStore for Coordinator::delete_character` no longer resolves the owning \ - shard through `realm_core::resolve_delete_shard` before deleting. Either a character \ - resident on a non-default shard is undeletable again, or — the \ - opposite failure — delete stopped running on exactly ONE shard, which is the \ - data-loss footgun this design explicitly refuses to introduce." - ); - } - - /// THE tripwire for the live world-entry path. `world::route_home` calls `settle_home_shard`, - /// and this file overrides it — so a rule applied in `home_shard` alone is production-dead - /// while both suites stay green (the mocks take the trait default, which forwards to - /// `home_shard`). That is the merge-defect class this test exists to make impossible to - /// reintroduce. - #[test] - fn settle_home_shard_keeps_the_short_circuit_and_the_escrow_first_destination() { - let code = code_of("settle_home_shard"); - // It must short-circuit before anything else on a single-database gateway, - // which is what "the env vars unset ⇒ today's gateway" means on the login hot path. - // Pin the NEGATED form, not the bare substring: `code.contains("is_sharded()")` - // still matched after the guard was inverted to `if self.is_sharded()`, which is the exact - // source-scan defeat class this repo keeps hitting (a scan that a mutation walks straight - // through). This body is unreachable from the suite — `Coordinator` cannot be constructed - // offline, so every mock takes the trait default — so this scan is the only guard there is, - // and it has to be precise enough to fail when the guard flips. - assert!( - code.contains("if !self.is_sharded() {"), - "settle_home_shard lost the single-shard short-circuit (or inverted it): an \ - unconfigured gateway would pay the shard scan and the escrow read on every world entry", - ); - // The owner handed to `run_transfer` must be resolved from the ESCROW's stated - // destination, not from whatever the durable row says, or a resumed transfer imports the - // blob into a database it was never filed against. `run_transfer` cannot check this itself. - let escrow_at = code - .find("escrow_row(character_guid)") - .unwrap_or(usize::MAX); - let location_at = code - .find("character_location(character_guid)") - .unwrap_or(usize::MAX); - assert!( - escrow_at < location_at, - "settle_home_shard must resolve the destination from the escrow row FIRST and fall back \ - to `character_location` — the other order hands `run_transfer` a `dst` that can differ \ - from the destination the escrow was opened for" - ); - } - - /// The same tripwire, for the instance-pool stickiness. `ShardMap::instance_owner` is pinned - /// by its own unit tests, but *whether the production world-entry resolver still calls it* is - /// unreachable from any fake: `world::tests`' mocks implement the TRAIT, and this rule lives in - /// the `Coordinator` impl. Deleting the call site restores the pre-stickiness routing — a live dungeon - /// run forks onto a newly added pool member on the next world entry — with every suite green. - #[test] - fn settle_home_shard_still_applies_the_instance_pool_stickiness() { - let code = code_of("settle_home_shard"); - // The ARGUMENTS, not just the name. Adversarial review: `contains("instance_shard_for(")` - // alone is defeated by every alias substitution that keeps the call — passing - // `self.shard_name()` instead of `holder.shard_name()` (the session's handle, which on a - // login is the DEFAULT shard and never a pool member, so stickiness can never fire) left - // all 391 gateway tests green, as did passing a literal `0` for `instance_id`. Both delete - // the feature outright while this scan stays satisfied. - assert!( - code.contains("instance_shard_for(map_id, instance_id, holder.shard_name())"), - "settle_home_shard no longer resolves an instance's owner through \ - `instance_shard_for(map_id, instance_id, holder.shard_name())` (pure half: \ - `config::ShardMap::instance_owner`). The HOLDER is the whole rule — it is the only \ - durable evidence of which pool member a live run is on, and `self` is the session's \ - handle, not the character's. Adding a second instances database would then re-point \ - the buckets of LIVE runs, and each of those parties would be transferred into a \ - freshly mirrored, empty copy of their own dungeon." - ); - // …and only on the no-escrow path. An in-flight escrow's destination is fixed by the row; - // being sticky there hands `run_transfer` a `dst` the blob was never filed against. - assert!( - code.contains("if escrow.is_none() {"), - "the stickiness arm lost its escrow guard — a resumed transfer would resolve an owner \ - that differs from the destination its escrow was opened for, orphaning the fenced copy" - ); - } - - #[test] - fn home_shard_still_resolves_through_the_settle_shard_index() { - let code = code_of("home_shard"); - // The single-shard short-circuit, the index hint, the probe and the self-heal - // live in `realm_core::settle_shard_index` — where they are BEHAVIOURALLY tested - // (`a_single_database_gateway_resolves_the_home_shard_without_reading_anything`, - // `a_deliberately_stale_index_entry_still_routes_correctly_and_is_healed_in_place`), which - // they never were while they sat inline here. What this scan still has to pin is that - // `home_shard` calls it at all: routing to `resolved` while nothing computed `resolved` - // from the index would drop the whole realm-core routing with every suite green. - assert!( - code.contains("settle_shard_index(self, character_guid)"), - "home_shard no longer resolves through `realm_core::settle_shard_index` — the realm-core \ - index hint, the confirming probe, the self-heal write-back and the single-database \ - short-circuit are all in it, so dropping the call drops realm-core routing entirely (and makes an \ - unconfigured gateway pay for routing it does not do)" - ); - } - - /// The ONE line that decides whether a party op runs on realm-core or on the - /// player's own shard, and the only one no fake can reach — `world::party`'s tests drive the - /// trait, and this is the `Coordinator` impl that answers it in production. - /// - /// Both directions are damage. Dropping the `is_sharded()` short-circuit routes a - /// SINGLE-DATABASE gateway's party ops through the operator-gated realm reducers against its own - /// database: a different reducer, a different gate, and a mirror push per op, in a configuration - /// the realm-core slice promises costs nothing. Dropping `realm_core()` — answering `self` — silently reinstates - /// the shard-local party plane this whole slice exists to remove, with every suite green because - /// every mock supplies its own realm handle. - #[test] - fn realm_store_is_the_realm_core_handle_and_only_on_a_multi_database_gateway() { - let code = code_of("realm_store"); - assert!( - code.contains("if !self.is_sharded() {") && code.contains("return None;"), - "realm_store lost its single-database short-circuit: an unconfigured gateway would route \ - every party op through the operator-gated `realm_group_op` and mirror it back onto the \ - one database it already wrote. The byte-identical promise is exactly this line." - ); - assert!( - code.contains("self.realm_core()"), - "realm_store no longer resolves the REALM-CORE handle. Answering any other database \ - puts party membership back on a world shard — a cross-shard invite stops resolving and \ - a split party stops seeing itself, which is the live defect this slice fixes." - ); - } - - /// The mirror fan-out set. `world_stores()` naming only SOME shards (or none) leaves the others - /// running a party's gameplay — kill-XP split, quest credit, loot rules, the dungeon binding — - /// against a roster that no longer exists. Realm-core must not be in it: it owns no gameplay - /// reads, and `ShardMap::shards()` is what excludes it by construction. - #[test] - fn world_stores_fans_out_to_every_world_shard_and_never_to_realm_core() { - let code = code_of("world_stores"); - assert!( - code.contains("self.all_shards()"), - "world_stores no longer enumerates the connected world shards through `all_shards()` \ - (which excludes realm-core by construction). A narrower set silently stops mirroring \ - realm-core's roster onto the shards it left out." - ); - assert!( - code.contains("if !self.is_sharded() {"), - "world_stores lost its single-database short-circuit — the mirror push must cost an \ - unconfigured gateway nothing" - ); - } - - #[test] - fn deleted_character_absence_uses_two_durable_snapshots_from_every_shard() { - let read = code_of("character_exists_on_any_world_shard"); - assert!( - read.contains("self.world_shards_for_absence()?") - && read - .matches("durable_character_presence_snapshots(&shards, guid)?") - .count() - == 2 - && read.contains("stable_character_absence(&first, &second)") - ); - - let src = include_str!("connection.rs"); - let body = crate::test_scan::code_of(src, "pub(crate) fn world_shards_for_absence(&self)"); - assert!( - body.contains(".shards()") - && body.contains("self.1.conns.get(&db)") - && body.contains("inner.coord().is_healthy()"), - "absence can only be established from every configured World Shard's healthy \ - Coordinator subscription. Body was:\n{body}" - ); - - let realm = code_of("party_cleanup_realm"); - assert!( - realm.contains("self.realm_core()") && !realm.contains(".ok()"), - "deleted Character cleanup must fail when configured Realm-core is unavailable. Body \ - was:\n{realm}" - ); - } - +mod stable_absence_tests { #[test] fn a_transfer_between_durable_scans_cannot_prove_stable_absence() { use super::{stable_character_absence, CharacterPresenceSnapshot}; diff --git a/gateway/src/stdb/world_view.rs b/gateway/src/stdb/world_view.rs index ea7f1fd0e..e9152778b 100644 --- a/gateway/src/stdb/world_view.rs +++ b/gateway/src/stdb/world_view.rs @@ -3412,32 +3412,6 @@ mod family_audience_tests { spacetimedb_sdk::Identity::from_byte_array([byte; 32]) } - #[test] - fn membership_mirror_drives_viewer_relative_loot_tag_flags() { - let source = include_str!("world_view.rs"); - let arm = crate::test_scan::code_of(source, "fn register_shard_callbacks"); - let arm: String = arm.split_whitespace().collect(); - assert!(arm.contains("wire_insert(db.game_group_member()")); - assert!(arm.contains("wire_delete(db.game_group_member()")); - assert_eq!(arm.matches("group_member_mirror_changed(").count(), 2); - - let group_event = crate::test_scan::code_of(source, "fn group_event_appeared"); - assert!(!group_event.contains("loot_tag_flags_after_membership_change")); - } - - /// A party frame sets every member's online flag behind the Member Stats Relay's back, so the - /// LIST job forgets the viewer's record and the next tick sends every field. - #[test] - fn a_party_frame_makes_member_stats_start_over() { - let source = include_str!("world_view.rs"); - let group_event: String = crate::test_scan::code_of(source, "fn group_event_appeared") - .split_whitespace() - .collect(); - assert!(group_event.contains( - "ifrow.kind==lyracore_shared::group::event_kind::LIST{viewer.member_stats.forget_all();}" - )); - } - fn viewer(session: u64, self_guid: u64) -> Arc { let (tx, _rx) = SessionTx::with_depth(0); viewer_with_tx(session, self_guid, identity(session as u8), tx) @@ -3489,11 +3463,6 @@ mod family_audience_tests { } } - /// Collapse all whitespace so structural source assertions survive rustfmt. - fn no_ws(source: &str) -> String { - source.split_whitespace().collect() - } - fn explored(area_bit: i32, area_id: u32, experience: u32) -> CharacterExplored { CharacterExplored { id: 1, @@ -4598,12 +4567,6 @@ mod family_audience_tests { assert!(!duel_winner_audience(20, 10, 20)); assert!(duel_winner_audience(30, 10, 20)); assert!(!duel_winner_audience(0, 10, 20)); - - let source = include_str!("world_view.rs"); - let body = crate::test_scan::code_of(source, "fn duel_event_appeared("); - assert!(body.contains("CellKey::of_position")); - assert!(body.contains("view.cell_audience(shard, Some(key), BOX_HALF_SPAN, &[])")); - assert!(body.contains("duel_winner_outbound")); } #[test] @@ -5167,117 +5130,6 @@ mod family_audience_tests { ); } - #[test] - fn migrated_owner_callbacks_are_armed_once_and_not_registered_per_session() { - let source = include_str!("world_view.rs"); - let start = source.find("pub(crate) fn arm_shard").unwrap(); - let end = source[start..] - .find("pub(crate) fn arm_realm_private") - .map(|offset| start + offset) - .unwrap(); - let arm = &source[start..end]; - for table in [ - "game_teleport_event", - "game_addon_message", - "game_xp_event", - "game_levelup_event", - "game_character_explored", - "game_player_reputation", - "game_item_instance", - "game_mail_arrival", - ] { - assert_eq!( - arm.matches(&format!("db.{table}()")).count(), - match table { - "game_player_reputation" => 2, - "game_item_instance" => 3, - _ => 1, - }, - "{table} must have the expected shared callbacks per arm_shard call" - ); - } - let arm_flat = no_ws(arm); - for (helper, label) in [ - ("wire_insert", "game_character_quest.insert"), - ("wire_update", "game_character_quest.update"), - ("wire_delete", "game_character_quest.delete"), - ] { - assert_eq!( - arm_flat - .matches(&format!("{helper}(db.game_character_quest()")) - .count(), - 1, - "game_character_quest must have one {helper} callback per arm_shard call" - ); - assert!(arm.contains(&format!("\"{label}\""))); - } - assert_eq!(arm.matches("\"game_item_instance.update\"").count(), 1); - assert_eq!(arm.matches("\"game_item_instance.delete\"").count(), 1); - - let subscriptions = include_str!("subscriptions.rs"); - let subscribe = subscriptions - .split("pub fn subscribe_player_events(") - .nth(1) - .and_then(|body| body.split("pub fn spawn_bot_invite_relay").next()) - .expect("subscribe_player_events body"); - for callback in [".on_insert(", ".on_update(", ".on_delete("] { - assert!( - !subscribe.contains(callback), - "subscribe_player_events must only register a viewer, not row callback {callback}" - ); - } - let guard = subscriptions - .split("pub struct PlayerSubscriptions") - .nth(1) - .and_then(|body| body.split("impl PlayerSubscriptions").next()) - .expect("PlayerSubscriptions fields"); - assert!( - !guard.contains("teardown") && !guard.contains("FnOnce"), - "PlayerSubscriptions must own viewer lifetime only" - ); - - let exploration = source - .split("fn exploration_appeared") - .nth(1) - .and_then(|body| body.split("fn exploration_outbound").next()) - .map(no_ws) - .expect("the exploration owner relay"); - assert!( - exploration.contains("viewer_of_owner_on_shard(shard,OwnerGuid(row.character_guid))") - && exploration.contains("enqueue(viewer.clone(),move|viewer|{exploration_outbound"), - "exploration must select its owner then defer all work to that viewer's writer" - ); - assert!( - subscriptions.find("explored.seed(").unwrap() - < subscriptions.find("view.add_viewer(").unwrap(), - "resident exploration state must be seeded before viewer registration" - ); - - assert_eq!( - include_str!("connection.rs") - .matches("super::world_view::arm_shard(") - .count(), - 2, - "arm_shard must run once at connect and once from the reconnect hook" - ); - - for handler in ["quest_inserted", "quest_updated", "quest_deleted"] { - let start = source.find(&format!("fn {handler}")).unwrap(); - let body = no_ws(&source[start..]); - assert!( - body.contains("view.viewer_of_owner_on_shard(shard,OwnerGuid(row.character_guid))") - && body.contains("enqueue(viewer.clone(),move|viewer|") - && body.contains("coord.0.coord()"), - "{handler} must select one shard-scoped owner and defer cache reads and packet construction to its writer job" - ); - } - let update = no_ws(&source[source.find("fn quest_updated").unwrap()..]); - assert!( - update.contains("quest_update_outbound(&guard.conn.db,self_guid,&old,&row)"), - "quest update must preserve its feedback and full-log ordering in one writer job" - ); - } - /// The reconnect contract, behaviorally: a re-arm builds a fresh callback generation over the /// same shared view (each generation is a `view.clone()` capture, exactly what `wire_insert` /// takes), and the viewer registered before the reconnect still receives owner relays through @@ -5428,64 +5280,6 @@ mod family_audience_tests { assert!(queued_job(&rx).is_empty(), "there are no resident rows"); assert!(rx.try_recv().is_err(), "one sweep must be one writer job"); } - - #[test] - fn world_entry_sweep_reads_and_builds_only_inside_its_writer_job() { - // Resident packet construction needs a live Coordinator. Keep its AOI/cache reads and - // packet builders in the same writer job without substituting internal collaborators. - let source = include_str!("world_view.rs"); - let signature = "pub(crate) fn sweep_into_view("; - let start = source.rfind(signature).unwrap(); - let sweep = crate::test_scan::code_of(&source[start..], signature); - let job = crate::test_scan::body_of(&sweep, "enqueue("); - let outside_job = sweep.replacen(&job, "", 1); - - assert_eq!(sweep.matches("enqueue(").count(), 1); - assert!( - !sweep.contains(".send("), - "the writer must send the returned packet sequence" - ); - for operation in [ - "visible_entities(", - ".shard(", - ".shards", - ".coord()", - "offer_peer_create_for(", - "relay_gameobject_create(", - "resident_taxi_spline_outbound(", - "resident_instance_removal_outbound(", - ] { - assert!( - job.contains(operation), - "the writer job must own {operation}" - ); - assert!( - !outside_job.contains(operation), - "the reader must not run {operation}" - ); - } - } - - #[test] - fn recenter_and_sweep_enqueue_world_entities_before_game_objects() { - let source = include_str!("world_view.rs"); - let recenter_start = source.rfind("pub(crate) fn recenter(view:").unwrap(); - let sweep_start = source.rfind("pub(crate) fn sweep_into_view(view:").unwrap(); - let recenter = &source[recenter_start..sweep_start]; - assert!( - recenter.find("delta.world_entities").unwrap() - < recenter.find("delta.game_objects").unwrap(), - "a recenter must enqueue world-entity visibility work before game-object work" - ); - - let seed_start = source.rfind("fn seed_shard_from_cache(view:").unwrap(); - let sweep = &source[sweep_start..seed_start]; - assert!( - sweep.find("EntityLayer::WorldEntity").unwrap() - < sweep.find("EntityLayer::GameObject").unwrap(), - "the initial sweep must enqueue world-entity visibility work before game-object work" - ); - } } #[cfg(test)] @@ -6089,9 +5883,8 @@ mod account_claim_relay_tests { /// **The other half of the fix:** the online mark is one-shot and starts unset, so a claim /// that closes for a Character this Gateway never watched come online — a login that fails /// after `claim_session` succeeds, before `enter_world`/`player_login` ever runs — has nothing - /// to consume. Combined with the next test (the mark is checked before anything else runs), - /// this is the "no notice at all" guarantee for a failed login; no Fake reaches a - /// Coordinator-backed `account_claim_changed` call to prove the end-to-end path directly. + /// to consume. No Fake reaches a Coordinator-backed `account_claim_changed` call to prove the + /// end-to-end "no notice at all" path for a failed login directly. #[test] fn take_character_online_answers_false_for_a_login_it_never_observed() { let view = WorldView::new(true); @@ -6100,47 +5893,6 @@ mod account_claim_relay_tests { "nothing marked this guid online, so there is nothing to consume" ); } - - /// Nothing is enqueued, and no team read is attempted, for a claim close whose Character this - /// Gateway never watched come online, or that no local viewer lists as a friend — pinned in - /// source because no Fake reaches a Coordinator-backed `claim_close_team` read to prove it - /// behaviorally. - #[test] - fn account_claim_changed_checks_the_online_mark_and_candidates_before_any_read() { - let body = - crate::test_scan::code_of(include_str!("world_view.rs"), "fn account_claim_changed("); - let closed_at = body - .find("claim_closed(old, new)") - .expect("must compute the close first"); - let mark_at = body - .find("take_character_online(subject_guid)") - .expect("must check the online mark before doing anything else"); - let candidates_at = body - .find("friend_notice_candidates(view, subject_guid)") - .expect("must collect candidates"); - let empty_check_at = body - .find("candidates.is_empty()") - .expect("must return early when nobody local is listening"); - let enqueue_at = body - .find("enqueue(viewer") - .expect("must still enqueue one job per candidate"); - let read_at = body - .find("claim_close_team(&coord, subject_guid)") - .expect("must still resolve the departing team"); - assert!( - closed_at < mark_at - && mark_at < candidates_at - && candidates_at < empty_check_at - && empty_check_at < enqueue_at, - "the online mark must be checked before candidates are even collected, so a login \ - that never reached player_login answers nothing, no matter who is listening" - ); - assert!( - enqueue_at < read_at, - "the team read (`claim_close_team`) must happen INSIDE the enqueued job, never before \ - it — the shared pump must not block on a read that can cross to another Shard" - ); - } } #[cfg(test)] diff --git a/gateway/src/stdb/world_view/cell_audience_tests.rs b/gateway/src/stdb/world_view/cell_audience_tests.rs index b8e496fc0..168a6a629 100644 --- a/gateway/src/stdb/world_view/cell_audience_tests.rs +++ b/gateway/src/stdb/world_view/cell_audience_tests.rs @@ -1304,39 +1304,6 @@ fn aura_audience_follows_a_viewer_recenter() { assert_eq!(sessions(aura_audience(&view, 0, &row)), HashSet::from([1])); } -/// Every aura read the relays make goes through the index; a whole-cache scan must not creep -/// back into a job or onto the pump. -#[test] -fn aura_relays_never_scan_the_aura_cache() { - let subscriptions = include_str!("../subscriptions.rs"); - for signature in [ - "pub(crate) fn offer_peer_create_for", - "pub(crate) fn aura_insert_outbound", - "pub(crate) fn aura_update_outbound", - "pub(crate) fn aura_delete_outbound", - ] { - let body = crate::test_scan::code_of(subscriptions, signature); - assert!( - !body.contains("game_aura()"), - "{signature} scans the aura cache" - ); - assert!( - body.contains("view.auras."), - "{signature} must read the aura index" - ); - } - let arm = crate::test_scan::code_of( - include_str!("../world_view.rs"), - "fn register_shard_callbacks", - ); - assert!( - !arm.contains(".game_aura().iter()"), - "the pump scans the aura cache" - ); - assert_eq!(arm.matches("view.auras.upsert(shard, row)").count(), 2); - assert_eq!(arm.matches("view.auras.remove(shard, row)").count(), 1); -} - fn queue_motion(batch: &super::super::movement_batch::MovementBatch, seq: u32) -> EntityMotion { batch.push(GwMove { actor: crate::stdb::bindings::SessionActor { diff --git a/gateway/src/test_scan.rs b/gateway/src/test_scan.rs deleted file mode 100644 index 9bc107951..000000000 --- a/gateway/src/test_scan.rs +++ /dev/null @@ -1,78 +0,0 @@ -//! Shared source-scan primitives for this crate's `#[cfg(test)]` wiring tripwires — a gateway-local -//! port of `module/src/test_scan.rs` (one hardened implementation instead of near-identical copies -//! that drift, comment-strip inconsistently, or fall for a needle planted in a trailing comment). -//! Ported rather than shared because the module and gateway are separate crates and -//! `module/src/test_scan.rs`'s functions are `pub(crate)` to that crate; the algorithm here is -//! otherwise identical. -//! -//! Only test-only (see the module doc there for why a source scan, not a mock, is this crate's own -//! answer to the same gap — `ReducerContext` doesn't apply here, but `DbConnection`/`Coordinator` -//! wiring that only a live SpacetimeDB node would exercise is the same shape of untestable-by-mock). -//! Declared behind `#[cfg(test)]` at the `mod` site in `main.rs`, matching `module/src/lib.rs`'s own -//! `#[cfg(test)] mod test_scan;`. - -/// Isolate a fn (or struct/const/etc.) body by brace-matching from the first byte offset where -/// `signature` appears. Panics loudly — never silently matches nothing — if the signature or a -/// balanced `{...}` cannot be found, because a scan that can't find its target has lost its pin, not -/// passed it. -pub(crate) fn body_of(src: &str, signature: &str) -> String { - let start = src - .find(signature) - .unwrap_or_else(|| panic!("`{signature}` no longer exists in this source")); - let rest = &src[start..]; - let open = rest.find('{').expect("fn has a body"); - let mut depth = 0i32; - for (i, c) in rest[open..].char_indices() { - match c { - '{' => depth += 1, - '}' => { - depth -= 1; - if depth == 0 { - return rest[open..=open + i].to_string(); - } - } - _ => {} - } - } - panic!("unterminated body for `{signature}`"); -} - -/// [`strip_line_comment`] for scanners that walk a whole file line by line rather than one extracted -/// body (`main.rs`'s `boundary_panic_tripwire`) — same stripper, so the two can never disagree about -/// what counts as a comment. -pub(crate) fn strip_comment(line: &str) -> &str { - strip_line_comment(line) -} - -/// Strip a Rust line comment from `line`, respecting simple double-quoted string literals so a `//` -/// inside one is never mistaken for a comment start. Does not understand raw strings or block -/// comments — nothing this crate's tripwires scan uses either, and a miss here fails LOUD (the -/// caller's assertion breaks), never silently. -fn strip_line_comment(line: &str) -> &str { - let bytes = line.as_bytes(); - let mut in_string = false; - let mut i = 0; - while i < bytes.len() { - match bytes[i] { - b'\\' if in_string => i += 1, - b'"' => in_string = !in_string, - b'/' if !in_string && bytes.get(i + 1) == Some(&b'/') => return &line[..i], - _ => {} - } - i += 1; - } - line -} - -/// [`body_of`] with every comment gone — a whole comment-only line dropped entirely, a trailing -/// comment on an otherwise-live line truncated at its `//`. Never `.contains()` a raw `body_of` -/// result; always go through this. -pub(crate) fn code_of(src: &str, signature: &str) -> String { - body_of(src, signature) - .lines() - .map(strip_line_comment) - .map(str::trim_end) - .filter(|l| !l.trim().is_empty()) - .collect::>() - .join("\n") -} diff --git a/gateway/src/world/handlers/meeting_stone.rs b/gateway/src/world/handlers/meeting_stone.rs index 8fe892b67..f656efc87 100644 --- a/gateway/src/world/handlers/meeting_stone.rs +++ b/gateway/src/world/handlers/meeting_stone.rs @@ -645,19 +645,6 @@ mod tests { assert!(dispatch_meeting_stone_action(&lost, in_world(), join_stone()).is_err()); } - /// The Coordinator picks the database, so a Fake cannot show it. The admission runs on the - /// session's own handle, which is its Home Shard; the queue op runs on Realm-core, which is the - /// one database of an unsharded Realm. - #[test] - fn the_coordinator_admits_at_home_and_queues_on_the_party_authority() { - let src = include_str!("../../stdb/reducers.rs"); - let admit = crate::test_scan::code_of(src, "pub fn admit_meeting_stone("); - assert!(admit.contains("self.0.call_pipe()") && !admit.contains("realm_core")); - let op = crate::test_scan::code_of(src, "pub fn meeting_stone_op("); - assert!(op.contains("let realm = self.realm_core()?;")); - assert!(op.contains("realm.0.call_pipe().conn.reducers")); - } - #[test] fn other_opcodes_pass_through() { let store = FakeMeetingStones::default(); diff --git a/gateway/src/world/party_tests.rs b/gateway/src/world/party_tests.rs index 3227445e0..ff88051f0 100644 --- a/gateway/src/world/party_tests.rs +++ b/gateway/src/world/party_tests.rs @@ -1107,44 +1107,6 @@ fn deleted_character_cleanup_flushes_pending_loot_before_leaving_realm_core() { ); } -#[test] -fn deleted_character_leave_returns_with_the_committed_roster_visible() { - let src = include_str!("../stdb/reducers.rs"); - let body = crate::test_scan::code_of(src, "pub fn deleted_character_party_leave("); - assert!( - body.contains("let coordinator = self.0.visibility_pipe()") - && body.contains("coordinator.conn.reducers"), - "cleanup reads Realm-core immediately after LEAVE, so the Durable Request must return a \ - Coordinator visibility receipt. Body was:\n{body}" - ); - - let ordinary = crate::test_scan::code_of(src, "pub fn realm_group_op("); - assert!( - ordinary.contains("self.0.call_pipe().conn.reducers"), - "an op that pushes no Group mirror needs no visibility receipt, so it keeps the \ - independent call pipe. Body was:\n{ordinary}" - ); - - let visible = crate::test_scan::code_of(src, "pub fn realm_group_op_visible("); - assert!( - visible.contains("let coordinator = self.0.visibility_pipe()") - && visible.contains("coordinator.conn.reducers"), - "a World Session's party op pushes the mirror from a read right after it, so it must \ - return a Coordinator visibility receipt. Body was:\n{visible}" - ); -} - -/// The production pipes cannot run in a Gateway test, so the choice of op in `party::run` is pinned -/// here and the lagging Fake above proves what it buys. -#[test] -fn a_world_session_runs_its_realm_party_op_on_the_visibility_pipe() { - let run = crate::test_scan::code_of(include_str!("party.rs"), "pub(crate) fn run<"); - assert!(run.contains("run_on_authority_visible(realm.as_ref(), self_guid, op, acceptor)")); - let visible = - crate::test_scan::code_of(include_str!("party.rs"), "fn run_on_authority_visible<"); - assert!(visible.contains("authority.realm_group_op_visible(")); -} - /// **The invariant this batch has broken five times: unset config changes NOTHING.** /// /// A single-database gateway has no realm-core to route to, so every op takes the pre-realm-core diff --git a/gateway/src/world/transfer_tests.rs b/gateway/src/world/transfer_tests.rs index d0cf83c91..892584bcb 100644 --- a/gateway/src/world/transfer_tests.rs +++ b/gateway/src/world/transfer_tests.rs @@ -508,46 +508,6 @@ fn an_unset_transfer_abort_injection_changes_nothing() { ); } -/// Source-scan tripwire for the one line no in-process test can reach: `run_transfer`'s ENV WIRING. -/// -/// Both tests above drive `run_transfer_injected` directly — deliberately, so a parallel test -/// runner never has process-global env mutated underneath it — which leaves the wrapper that -/// actually arms the injector in production completely unexercised. Found by mutation during this -/// PR's review: replacing the call's last argument with a literal `None` (the injector still -/// present, still compiled, permanently DISARMED) left all 370 gateway tests GREEN, while -/// `LYRACORE_TRANSFER_ABORT_AFTER` did nothing and every step of the live crash-recovery matrix -/// would time out waiting for a death that can no longer happen. -/// -/// The unmatched-step warning is pinned here for the same reason: it is the only thing standing -/// between a typo'd step name and a crash matrix that reports PASS for a crash that never fired, -/// and no in-process test asserts a log line. -#[test] -fn run_transfer_still_arms_the_injector_from_the_environment() { - let src = include_str!("transfer.rs"); - let at = src - .find("pub fn run_transfer(") - .expect("`run_transfer` moved"); - let end = src[at..].find("\n}\n").expect("`run_transfer` body"); - let body = &src[at..at + end]; - assert!( - body.contains("std::env::var(\"LYRACORE_TRANSFER_ABORT_AFTER\")"), - "`run_transfer` no longer reads LYRACORE_TRANSFER_ABORT_AFTER — the injector is dead in the \ - PRODUCTION build (the tests call `run_transfer_injected` directly and stay green). Body \ - was:\n{body}" - ); - assert!( - body.contains("run_transfer_injected(src, dst, plan, abort_after.as_deref())"), - "`run_transfer` reads the env but no longer THREADS it into `run_transfer_injected` — the \ - read is decorative and every crash point is permanently disarmed. Body was:\n{body}" - ); - assert!( - body.contains("ABORT_STEPS.contains(&step)"), - "`run_transfer` no longer validates the step name against `ABORT_STEPS` — a typo'd \ - LYRACORE_TRANSFER_ABORT_AFTER would then abort NOTHING, silently, and the crash matrix would \ - report a PASS for a crash that never happened. Body was:\n{body}" - ); -} - #[test] fn the_driver_never_attests_an_import_that_did_not_commit() { // `confirm_import` files the in-row that licenses `finish_transfer` to CASCADE-DELETE the @@ -959,47 +919,6 @@ fn entering_the_world_binds_this_accounts_identity_on_the_shard_it_landed_on() { ); } -/// ENFORCEMENT tripwire, the module's `body_of` pattern: the production routing read lives on -/// `Coordinator` and needs a live SDK cache, so no mock can drive it — and a mutation of it -/// survived the first cut of this file's mutation pass. Source-scan it instead. -#[test] -fn the_routing_read_uses_the_pending_instance_id_not_a_hardcoded_zero() { - let src = include_str!("../stdb/reads/account.rs"); - let start = src - .find("pub fn character_location(") - .expect("`character_location` moved — re-derive this tripwire"); - let body = &src[start..start + src[start..].find("\n }").expect("fn has a body")]; - let code: String = body - .lines() - .filter(|l| !l.trim_start().starts_with("//")) - .collect::>() - .join("\n"); - assert!( - code.contains("c.pending_instance_id"), - "character_location no longer reads `pending_instance_id` for a character with no live \ - entity. That column is where `teleport_player` parks the DESTINATION instance for a \ - cross-map hop, so it is the whole routing key for instance entry: reading 0 there \ - routes a player walking into Deadmines by MAP alone, which is correct only until a shard \ - map names a bucket (`389:0=pool-a`, see `config::ShardMap`). Body was:\n{code}" - ); -} - -/// Sibling tripwire (same reason — a live SDK cache no mock reaches): a character parked inside a -/// dungeon lives on the INSTANCE shard, and Phase A has no realm-core index to ask. -#[test] -fn the_character_select_list_still_unions_across_every_shard() { - let ws = include_str!("../stdb/world_store.rs"); - let at = ws - .find("fn characters(&self, account_id: u64)") - .expect("`characters` moved"); - assert!( - ws[at..at + 900].contains("self.all_shards()"), - "the character-select list no longer unions across shards — asking only the realm \ - database makes a character that logged out inside an instance vanish from character \ - select entirely, because its durable row is on the instance shard." - ); -} - // The escrow-priority tripwire that used to live here (`locate_character_still_prefers_the_shard_ // holding_the_escrow`, a source scan of `Coordinator::locate_character`) was retired once // `settle_home_shard`'s holder lookup became `realm_core::locate_home_shard`, generic over the @@ -1008,46 +927,6 @@ fn the_character_select_list_still_unions_across_every_shard() { // `realm_core.rs`, which runs the real fallback-scan code against `fake::Handle` rather than // matching its source text. -/// Sibling tripwire: what `Coordinator::instance_shard_for` actually FORWARDS. -/// -/// `ShardMap::instance_owner` is pinned by its own unit tests and the call site in -/// `settle_home_shard` is pinned by `routing_call_site_tests`, but the three-line adapter between -/// them is reachable from neither — it needs a live `ShardSet`. Verified by mutation: each of the -/// three substitutions below left all 391 gateway tests green while deleting or inverting the -/// stickiness rule outright. -#[test] -fn instance_shard_for_still_forwards_the_holder_the_instance_and_the_connected_set() { - let conn = include_str!("../stdb/connection.rs"); - let at = conn - .find("pub(crate) fn instance_shard_for(") - .expect("`instance_shard_for` moved"); - let body: String = conn[at..at + 500] - .lines() - .filter(|l| !l.trim_start().starts_with("//")) - .collect::>() - .join("\n"); - // (a) the HOLDER, not `self`. `self` is the session's handle — on a login that is the default - // shard, which is never a member of a dungeon map's pool, so stickiness could never fire. - // (b) the real `instance_id`. A literal `0` makes `instance_owner`'s open-world guard reject - // every call, and the map decides again — i.e. live runs fork on a pool resize. - // (c) the CONNECTED predicate. `|_| true` would return a holder the gateway never reached, - // which `shard_handle` then cannot resolve, pinning the session to whatever asked. - assert!( - body.contains("instance_owner(map_id, instance_id, holder,"), - "instance_shard_for no longer forwards (map_id, instance_id, holder) verbatim to \ - `ShardMap::instance_owner`. The holder is the ONLY durable evidence of which pool \ - member a live dungeon run is on; substituting `self.shard_name()` or a literal instance \ - id silently restores the routing that predates the instance-shard pool and forks every \ - live run when the operator adds a second instances database. Body was:\n{body}" - ); - assert!( - body.contains("self.1.conns.contains_key(d)"), - "instance_shard_for's `connected` predicate no longer reads the live connection set — a \ - stickiness answer naming a database the gateway never reached cannot be routed to, and \ - must degrade to the shard map like every other resolver in `config.rs`. Body was:\n{body}" - ); -} - #[test] fn a_resumed_transfer_reuses_the_escrowed_destination_not_the_character_row() { // Resume authority: once an escrow exists, ITS destination is the one the destination shard may From 8a510e4d02688716bc30bae3faeaa9b4983478ca Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 13:40:53 +0200 Subject: [PATCH 04/21] test(module): name each Architecture Test's invariant and prove it fires Each retained Architecture Test now opens its failure message with the invariant it protects. The spatial-scan, shard-id, transfer-fence and gc-reap scanners gain a negative test that feeds them a small violating source. The gc-reap scan ignores comments in `gc.rs`. Delete the issue-reference ban and the dead-code allowance rule. The print-macro ban becomes a grep in the `fmt` CI job with the same scope: `module/src` and every `packages/*/src`. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/rust.yml | 4 + module/src/tripwires.rs | 526 ++++++++----------------------------- 2 files changed, 112 insertions(+), 418 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index d1a7e0f6d..89c6ba859 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -22,6 +22,10 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Check formatting run: cargo fmt --all -- --check + # The SpacetimeDB build aborts on a print-family macro anywhere in Module source, even in a + # comment or a test. Use `writeln!(std::io::stderr(), ...)` for a test diagnostic. + - name: Check for print macros in Module source + run: "! grep -rnE --include='*.rs' 'e?print(ln)?!' module/src packages/*/src" clippy: runs-on: ubuntu-latest diff --git a/module/src/tripwires.rs b/module/src/tripwires.rs index 8bcbb1a70..8688ec9c9 100644 --- a/module/src/tripwires.rs +++ b/module/src/tripwires.rs @@ -1,30 +1,18 @@ -//! Source-scan tripwires: `lib.rs` used to open with "this is the thin index" while -//! carrying 900+ lines of `#[cfg(test)]` scan machinery below its real index — pulled out here so -//! that doc comment stays true. `#[cfg(test)] mod tripwires;` is `lib.rs`'s only mention of this -//! file. +//! Architecture Tests: source scans for structural invariants that behaviour tests cannot observe. +//! Each failure message opens with the invariant it protects, and each scanner has a negative test +//! that feeds it a small violating source. //! -//! Ten tripwires, in file order: -//! - [`character_owned_tripwire`] — every table with a Character-capable guid field has a -//! `character_owned` sweep marker or an explicit exclusion. -//! - [`build_scan_strip_tripwire`] — a commented-out marker invocation never registers. -//! - [`partition_discipline_tripwire`] — no raw whole-table scan of a spatial table outside the -//! partition-scoped helpers, plus its extension (no module game logic reads a shard id). -//! - [`character_fence_tripwire`] — no raw `game_character` lookup outside the by-guid chokepoint. -//! - [`print_macro_tripwire`] — no print-family macro anywhere under `module/src` (new). -//! - [`gc_reap_tripwire`] — every TTL-shaped event table is named in `gc.rs` (new). -//! - [`grid_cell_tripwire`] — indexed cell fields are derived from their grid coordinates. -//! - [`issue_reference_tripwire`] — no comment carries a tracker issue reference. -//! - [`dead_code_allowance_tripwire`] — dead-code allowances stay confined to declared boundaries. -//! - [`package_name_tripwire`] — Core source names no official Package. +//! - [`character_owned_tripwire`]: every table with a Character-capable guid has a +//! `character_owned` sweep marker or a reasoned exclusion. +//! - [`build_scan_strip_tripwire`]: a commented-out marker invocation never registers. +//! - [`partition_discipline_tripwire`]: no whole-table scan of a spatial table outside the budget, +//! and no Module game logic reads a shard id. +//! - [`character_fence_tripwire`]: no raw `game_character` lookup outside the transfer fence. +//! - [`gc_reap_tripwire`]: every TTL-shaped event table is reaped in `gc.rs`. +//! - [`grid_cell_tripwire`]: a grid-coordinate write also writes the packed cell. +//! - [`package_name_tripwire`]: Core source names no official Package. //! -//! `partition_discipline_tripwire::raw_scans` and `character_fence_tripwire::raw_lookups` used to -//! be ~70-line near-clones — identical bound-handle walk-back, handle dedup, comment-line filtering -//! — that had already drifted from each other in small ways neither review caught. The exact drift -//! pattern consolidated once already, one file down (`body_of`/`code_of` into -//! `test_scan.rs`). Both now defer to the one engine there, [`crate::test_scan::raw_table_reads`]: -//! they differ only in which accessor(s) they watch and what counts as "opens a read" (`.iter()` / -//! `.count()` for a whole-table scan vs `.guid().find(` for a raw point lookup), so the two can no -//! longer disagree about what a table read is. +//! The spatial and fence scanners share one engine, [`crate::test_scan::raw_table_reads`]. /// ENFORCEMENT tripwire: `build.rs`'s codegen only registers tables that remembered to /// add a `character_owned` marker — it can't catch a table that forgot to. This test independently @@ -264,7 +252,7 @@ pub(crate) mod character_owned_tripwire { /// Replace comments, literals, and `#[cfg(test)]` items with spaces while preserving byte offsets /// and newlines. The scanner can then use simple balanced-delimiter walks without accepting a /// declaration that production never compiles. - fn production_code(content: &str) -> String { + pub(super) fn production_code(content: &str) -> String { fn blank(bytes: &mut [u8], start: usize, end: usize) { for byte in &mut bytes[start..end] { if *byte != b'\n' { @@ -630,7 +618,8 @@ pub(crate) mod character_owned_tripwire { assert!( missing.is_empty(), - "table(s) with a Character-capable guid have no matching `character_owned` sweep marker \ + "Invariant: deleting or re-owning a Character sweeps every table keyed by its guid.\n\ + table(s) with a Character-capable guid have no matching `character_owned` sweep marker \ or `NOT_CHARACTER_OWNED` classification: {missing:?}\n\ Add a `crate::character_owned` `delete` marker invocation (and usually the `restamp` \ form too, see the macro doc at the top of lib.rs), or add the accessor to \ @@ -937,6 +926,18 @@ mod partition_discipline_tripwire { crate::test_scan::raw_table_reads(content, SPATIAL_ACCESSORS, opens_whole_table_read) } + #[test] + fn the_scan_counts_inline_and_bound_handle_scans_but_not_comments() { + let src = + "fn f(ctx: &ReducerContext) {\n for e in ctx.db.game_world_entity().iter() {}\n \ + let spawns = ctx.db.game_creature_spawn();\n let n = spawns.count();\n \ + // ctx.db.game_world_entity().iter()\n}\n"; + assert_eq!( + raw_scans(src), + vec![(2, "game_world_entity"), (4, "game_creature_spawn")] + ); + } + #[test] fn no_unwhitelisted_raw_spatial_scans() { let root = repo_root(); @@ -969,9 +970,9 @@ mod partition_discipline_tripwire { assert!( violations.is_empty(), - "raw spatial-table scan(s) outside the partition-scoped helpers:\n {}\n\n\ - A whole-table `.iter()` over a spatial table is not shardable (issue #14 / spec #12): \ - after a region or continent split it silently sees only the caller's own shard. Use \ + "Invariant: no whole-table scan of a spatial table outside the partition-scoped \ + helpers.\nraw spatial-table scan(s) over budget:\n {}\n\n\ + A whole-table `.iter()` over a spatial table is not shardable: after a region or continent split it silently sees only the caller's own shard. Use \ `crate::helpers::entities_near(ctx, map_id, instance_id, x, y, radius)` for a radius \ search (grid-indexed, `(map_id, instance_id)`-scoped) and \ `crate::helpers::in_same_partition(&e, map_id, instance_id)` to test partition \ @@ -1066,6 +1067,14 @@ mod partition_discipline_tripwire { out } + #[test] + fn the_scan_finds_a_shard_field_read_but_not_prose_or_longer_names() { + let src = "fn f(ctx: &ReducerContext, row: Row) {\n if row.shard == DB {}\n \ + // row.shard is prose\n let name = row.shard_name;\n \ + let loc = ctx.db.game_character_shard();\n}\n"; + assert_eq!(shard_id_reads(src), vec![(2, ".shard")]); + } + /// AC#1 of **region definitions are data; shard ids are the gateway's business.** The /// module stores `region_assignment { map_id, region_id, shard, epoch }` (it has to — realm-core /// is this same wasm under another database name), but the instant a reducer *branches* on @@ -1116,8 +1125,9 @@ mod partition_discipline_tripwire { } assert!( violations.is_empty(), - "module code outside {SHARD_ID_OWNERS:?} reads a SHARD ID:\n {}\n\n\ - A shard is a database name — a GATEWAY routing fact (issue #23 / spec #12). Module \ + "Invariant: no Module game logic reads a shard id.\n\ + module code outside {SHARD_ID_OWNERS:?} reads a SHARD ID:\n {}\n\n\ + A shard is a database name, a GATEWAY routing fact. Module \ game logic may read cells (`lyracore_shared::spatial`) and regions \ (`lyracore_shared::region::RegionMap`), and may partition by `(map_id, instance_id)` via \ `crate::helpers`, but must never branch on which database owns a region: that is what \ @@ -1158,10 +1168,10 @@ mod partition_discipline_tripwire { /// character as ABSENT so each caller's existing "no such character" arm fires. This test /// source-scans the same file set as its two sibling tripwires for RAW character lookups and fails /// on any file over its whitelisted budget. `WHITELIST` FREEZES today's audited exceptions in -/// Core's own source, each with a verdict (see the verdict table in `transfer/mod.rs`'s module doc, -/// and `docs/history/transfer-by-guid-verdict-table.md` for each verdict's reasoning); it is a -/// ratchet, so it should only ever shrink. `PACKAGE_FILE_BUDGET` carries the same budget for an -/// installed Package's file, keyed by file shape instead of by Package, so Core names no Package. +/// Core's own source, each with a verdict (see the verdict table in `transfer/mod.rs`'s module doc); +/// it is a ratchet, so it should only ever shrink. `PACKAGE_FILE_BUDGET` carries the same budget +/// for an installed Package's file, keyed by file shape instead of by Package, so Core names no +/// Package. /// // Note: same (file, count) granularity, same text-scan mechanism, and the same two ceilings as // `partition_discipline_tripwire` — swapping one whitelisted lookup for a different one inside an @@ -1280,6 +1290,15 @@ mod character_fence_tripwire { out } + #[test] + fn the_scan_counts_raw_lookups_and_deletes_but_not_the_fence() { + let src = "fn f(ctx: &ReducerContext, guid: u64) {\n \ + let c = ctx.db.game_character().guid().find(guid);\n \ + let chars = ctx.db.game_character();\n chars.guid().delete(guid);\n \ + let fenced = crate::helpers::character_by_guid(ctx, guid);\n}\n"; + assert_eq!(raw_lookups(src), vec![2, 4]); + } + #[test] fn no_unwhitelisted_raw_character_lookups() { let root = repo_root(); @@ -1309,9 +1328,10 @@ mod character_fence_tripwire { assert!( violations.is_empty(), - "raw `game_character` lookup(s) outside the by-guid chokepoint:\n {}\n\n\ + "Invariant: every Character lookup goes through the transfer fence.\n\ + raw `game_character` lookup(s) outside the by-guid chokepoint:\n {}\n\n\ A reducer that reaches a character by guid or by name passes NONE of the escrowed-\ - transfer chokepoints (issue #30 / spec #12), so cross-database it can write to a source \ + transfer chokepoints, so cross-database it can write to a source \ copy the destination already serialized past — a lost write. Route the READ through \ `crate::helpers::character_by_guid(ctx, guid)` or \ `crate::helpers::character_by_name(ctx, name)`; both read an in-transit character as \ @@ -1355,66 +1375,6 @@ mod character_fence_tripwire { } } -/// ENFORCEMENT tripwire: SpacetimeDB's own CLI (2.7.1) source-scans this whole crate -/// for the print family — four macro names, each followed by a `!` — and aborts the build the -/// instant one appears ANYWHERE under `module/src`, including behind `#[cfg(test)]`: the scan is -/// source-level and does not know what `cfg` means. The trigger was three diagnostic notes in test-only -/// code (`publish_safety.rs` ×2, `test_scan.rs` ×1) that never compile into the wasm at all, yet -/// still failed every fresh clone's first `preflight` — the alpha's own start command, blocked for -/// everyone before a single reducer ever ran. Fixed there by swapping each to -/// `writeln!(std::io::stderr(), ...)`, which the CLI's list does not name, wrapped in `let _ = ...;` -/// so the ignored `Result` raises no warning. -/// -/// This pins that fix from coming apart again: no file under `module/src` (or a drop-in -/// `packages/*/src`) may spell one of those four names immediately followed by `!`. The needles -/// are assembled from two literal halves at RUN time — the same self-exclusion -/// `creatures::tick::relay_tripwire`'s move-event needle already uses (see its doc comment) — so -/// the forbidden spelling never sits contiguously in this file's own source. That matters twice -/// over: contiguous text here would (a) match itself in the scan below, and (b) trip the very -/// SpacetimeDB build scan this test exists to keep quiet, since that scan cannot tell a -/// `#[cfg(test)]` tripwire body apart from live reducer code either. -#[cfg(test)] -pub(crate) mod print_macro_tripwire { - /// The four forbidden spellings, each built from two halves so neither ever appears whole in - /// this source file. - fn needles() -> [String; 4] { - [ - format!("{}{}", "println", "!"), - format!("{}{}", "print", "!"), - format!("{}{}", "eprintln", "!"), - format!("{}{}", "eprint", "!"), - ] - } - - #[test] - fn no_print_family_macro_under_module_src() { - let mut violations = Vec::new(); - for file in super::character_owned_tripwire::scanned_files() { - let content = std::fs::read_to_string(&file) - .unwrap_or_else(|e| panic!("cannot read {}: {e}", file.display())); - for needle in needles() { - for (idx, _) in content.match_indices(needle.as_str()) { - violations.push(format!( - "{}:{} contains `{needle}`", - file.display(), - crate::test_scan::line_of(&content, idx) - )); - } - } - } - assert!( - violations.is_empty(), - "print-family macro found under module/src or a drop-in package (issue #432):\n {}\n\n\ - SpacetimeDB's CLI source-scans this whole crate and aborts the build on any of these — \ - even behind #[cfg(test)], because the scan is source-level and does not understand cfg. \ - For a test-only diagnostic, use `writeln!(std::io::stderr(), ...)` (needs \ - `use std::io::Write;`) instead, wrapped in `let _ = ...;` so the ignored Result does \ - not warn.", - violations.join("\n ") - ); - } -} - /// ENFORCEMENT tripwire: `gc.rs` has a hand-maintained `reap!` list. It covers the /// fifteen event tables reaped on their 1s `EVENT_TTL_MICROS` and the handful of tables with their /// own ad-hoc TTL block below it. A NEW short-lived event table @@ -1454,12 +1414,33 @@ mod gc_reap_tripwire { "game_bot_transfer_intent", ]; - /// `gc.rs` actually reaps `accessor` — via the shared `reap!(accessor)` macro invocation, or a - /// direct `ctx.db.accessor()` call (the shape of the ad-hoc blocks: `game_group_invite`'s own - /// TTL, and `corpse::sweep_corpse_decay`'s own `ctx.db.game_corpse()`, called from here). - fn gc_reaps(gc_src: &str, accessor: &str) -> bool { - gc_src.contains(&format!("reap!({accessor})")) - || gc_src.contains(&format!("ctx.db.{accessor}()")) + /// `gc.rs` reaps `accessor` through `reap!(accessor)` or a direct `ctx.db.accessor()` call. + /// Comments and string literals do not count. + fn gc_reaps(gc_code: &str, accessor: &str) -> bool { + gc_code.contains(&format!("reap!({accessor})")) + || gc_code.contains(&format!("ctx.db.{accessor}()")) + } + + /// The `id: u64` primary key plus `created_at: Timestamp` shape every TTL-reaped table uses. + /// Full field names, because `id: u64` alone is a substring of every `*_guid: u64` field. + fn is_ttl_shaped(fields: &str) -> bool { + fields.contains("pub id: u64,") + && (fields.contains("pub created_at: Timestamp,") + || fields.contains("pub created_at: spacetimedb::Timestamp,")) + } + + /// Every TTL-shaped, non-exempt table declared in `content` that `gc_src` never reaps. + fn unreaped_tables(content: &str, gc_src: &str) -> Vec { + let gc_code = super::character_owned_tripwire::production_code(gc_src); + super::character_owned_tripwire::extract_tables(content) + .into_iter() + .filter(|(accessor, fields)| { + is_ttl_shaped(fields) + && !EXEMPT_ACCESSORS.contains(&accessor.as_str()) + && !gc_reaps(&gc_code, accessor) + }) + .map(|(accessor, _)| accessor) + .collect() } #[test] @@ -1475,24 +1456,15 @@ mod gc_reap_tripwire { } let content = std::fs::read_to_string(&file) .unwrap_or_else(|e| panic!("cannot read {}: {e}", file.display())); - for (accessor, fields) in super::character_owned_tripwire::extract_tables(&content) { - // Full field names, not "id: u64" / "created_at:" bare — those are substrings of - // *_guid: u64` fields, which are on almost every table here. - let is_ttl_shaped = fields.contains("pub id: u64,") - && (fields.contains("pub created_at: Timestamp,") - || fields.contains("pub created_at: spacetimedb::Timestamp,")); - if !is_ttl_shaped || EXEMPT_ACCESSORS.contains(&accessor.as_str()) { - continue; - } - if !gc_reaps(&gc_src, &accessor) { - missing.push(format!("{accessor} (in {})", file.display())); - } + for accessor in unreaped_tables(&content, &gc_src) { + missing.push(format!("{accessor} (in {})", file.display())); } } assert!( missing.is_empty(), - "table(s) shaped `id: u64` PK + `created_at: Timestamp` — the TTL-reaped convention \ + "Invariant: every TTL-shaped event table is reaped in gc.rs.\n\ + table(s) shaped `id: u64` PK + `created_at: Timestamp` — the TTL-reaped convention \ every existing short-lived event table uses — with no reap in gc.rs: {missing:?}\n\n\ A short-lived event table only stops growing without bound if something deletes its \ rows. Add `reap!();` to `reap_movement_events` in `gc.rs` (the 1s \ @@ -1503,6 +1475,21 @@ mod gc_reap_tripwire { above with a comment justifying why it is intentionally unreaped." ); } + + #[test] + fn the_scan_flags_a_ttl_table_that_gc_only_mentions_in_a_comment() { + let table = "#[table(accessor = game_fixture_event)]\n\ + pub struct FixtureEvent {\n #[primary_key]\n pub id: u64,\n \ + pub created_at: Timestamp,\n}\n"; + let mentioned = "fn reap(ctx: &ReducerContext) {\n // reap!(game_fixture_event);\n}\n"; + assert_eq!( + unreaped_tables(table, mentioned), + vec!["game_fixture_event"] + ); + + let reaped = "fn reap(ctx: &ReducerContext) {\n reap!(game_fixture_event);\n}\n"; + assert!(unreaped_tables(table, reaped).is_empty()); + } } // ================================================================================================= @@ -1798,7 +1785,8 @@ pub(crate) mod grid_cell_tripwire { assert!( violations.is_empty(), - "a `grid_x` write with no `cell` write beside it (#456):\n {}\n\n\ + "Invariant: a grid-coordinate write also writes the packed `cell`.\n\ + a `grid_x` write with no `cell` write beside it:\n {}\n\n\ `cell` packs `(grid_x, grid_y)` into the one indexed value the AOI subscription probes \ by equality — it is what makes the box query index-served instead of a full partition \ scan. It is a plain column, so nothing updates it for you: write it in the SAME \ @@ -1926,305 +1914,6 @@ pub(crate) mod grid_cell_tripwire { } } -/// ENFORCEMENT tripwire: a comment must stay understandable without tracker history, so no comment -/// under `module/src` may carry an issue reference (a hash sign followed by 2..=4 digits). The -/// allow-list below records exact counts. Cleanup updates the count, and a file at zero leaves the -/// list entirely. -#[cfg(test)] -pub(crate) mod issue_reference_tripwire { - /// `(path under `module/src`, tracker references still tolerated there)`. - const RATCHET: &[(&str, usize)] = &[]; - - /// Returns one line number for each tracker reference in a comment. A reference is a hash sign - /// opening a 2..=4 digit token; a build, opcode, patch or revision number is not one. - fn tracker_refs(content: &str) -> Vec { - let bytes = content.as_bytes(); - let mut out = Vec::new(); - let mut i = 0; - - while i < bytes.len() { - if let Some(end) = raw_string_end(bytes, i) { - i = end; - continue; - } - - match bytes[i] { - b'/' if bytes.get(i + 1) == Some(&b'/') => { - let end = bytes[i + 2..] - .iter() - .position(|byte| *byte == b'\n') - .map_or(bytes.len(), |offset| i + 2 + offset); - comment_refs(content, i + 2, end, &mut out); - i = end; - } - b'/' if bytes.get(i + 1) == Some(&b'*') => { - i = block_comment_refs(content, i + 2, &mut out); - } - b'"' => i = quoted_end(bytes, i, b'"'), - b'\'' => i = char_literal_end(bytes, i).unwrap_or(i + 1), - _ => i += 1, - } - } - out - } - - fn comment_refs(content: &str, start: usize, end: usize, out: &mut Vec) { - for (offset, byte) in content.as_bytes()[start..end].iter().enumerate() { - let index = start + offset; - if *byte == b'#' && is_tracker_ref(content, index) { - out.push(crate::test_scan::line_of(content, index)); - } - } - } - - fn block_comment_refs(content: &str, mut i: usize, out: &mut Vec) -> usize { - let bytes = content.as_bytes(); - let mut depth = 1; - while i < bytes.len() && depth > 0 { - if bytes.get(i..i + 2) == Some(b"/*") { - depth += 1; - i += 2; - } else if bytes.get(i..i + 2) == Some(b"*/") { - depth -= 1; - i += 2; - } else { - if bytes[i] == b'#' && is_tracker_ref(content, i) { - out.push(crate::test_scan::line_of(content, i)); - } - i += 1; - } - } - i - } - - fn raw_string_end(bytes: &[u8], start: usize) -> Option { - let mut quote = start; - if bytes.get(quote) == Some(&b'b') { - quote += 1; - } - if bytes.get(quote) != Some(&b'r') { - return None; - } - quote += 1; - let hashes = bytes[quote..] - .iter() - .take_while(|byte| **byte == b'#') - .count(); - quote += hashes; - if bytes.get(quote) != Some(&b'"') { - return None; - } - - let mut i = quote + 1; - while i < bytes.len() { - if bytes[i] == b'"' - && bytes.get(i + 1..i + 1 + hashes) == Some(&bytes[quote - hashes..quote]) - { - return Some(i + 1 + hashes); - } - i += 1; - } - Some(bytes.len()) - } - - fn quoted_end(bytes: &[u8], mut i: usize, quote: u8) -> usize { - i += 1; - while i < bytes.len() { - if bytes[i] == b'\\' { - i += 2; - } else if bytes[i] == quote { - return i + 1; - } else { - i += 1; - } - } - bytes.len() - } - - fn char_literal_end(bytes: &[u8], start: usize) -> Option { - if bytes - .get(start + 1) - .is_some_and(|byte| byte.is_ascii_alphabetic() || *byte == b'_') - && bytes.get(start + 2) != Some(&b'\'') - { - return None; - } - - let end = (start + 9).min(bytes.len()); - let mut i = start + 1; - while i < end { - if bytes[i] == b'\\' { - i += 2; - } else if bytes[i] == b'\'' { - return Some(i + 1); - } else { - i += 1; - } - } - None - } - - fn is_tracker_ref(content: &str, index: usize) -> bool { - let bytes = content.as_bytes(); - let digits = bytes[index + 1..] - .iter() - .take_while(|byte| byte.is_ascii_digit()) - .count(); - if !(2..=4).contains(&digits) - || bytes - .get(index + 1 + digits) - .is_some_and(u8::is_ascii_digit) - { - return false; - } - - let before = content[..index].trim_end_matches(|character: char| { - character.is_ascii_whitespace() || character == '(' || character == '[' - }); - let word = before - .rsplit(|character: char| !character.is_ascii_alphabetic()) - .next() - .unwrap_or_default(); - !["build", "opcode", "patch", "rev", "revision"] - .iter() - .any(|exception| word.eq_ignore_ascii_case(exception)) - } - - fn ratchet_mismatch(path: &str, found: usize, budget: usize) -> Option { - (found != budget).then(|| format!("{path}: {found} refs (ratchet {budget})")) - } - - fn ratchet_file_is_missing(path: &str, seen: &[String]) -> bool { - !seen.iter().any(|seen_path| seen_path == path) - } - - #[test] - fn no_comment_carries_a_tracker_issue_reference() { - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); - let mut files = Vec::new(); - super::character_owned_tripwire::collect_rs_files(&root, &mut files); - let mut violations = Vec::new(); - let mut seen = Vec::new(); - for file in files { - let rel = file - .strip_prefix(&root) - .expect("scanned under module/src") - .to_string_lossy() - .replace('\\', "/"); - let content = std::fs::read_to_string(&file).expect("readable module source"); - let lines = tracker_refs(&content); - seen.push(rel.clone()); - if let Some((_, budget)) = RATCHET.iter().find(|(path, _)| *path == rel) { - if let Some(mismatch) = ratchet_mismatch(&rel, lines.len(), *budget) { - violations.push(format!("{mismatch}, lines {lines:?}")); - } - } else if !lines.is_empty() { - violations.push(format!( - "{rel}: {} refs (ratchet 0), lines {lines:?}", - lines.len() - )); - } - } - for (path, _) in RATCHET { - if ratchet_file_is_missing(path, &seen) { - violations.push(format!("{path}: ratchet file is missing")); - } - } - assert!( - violations.is_empty(), - "tracker issue references found in module/src comments:\n {}\n\n\ - Record the reasoning in the comment itself: a reader with no tracker access must still \ - understand why the constraint exists.", - violations.join("\n ") - ); - } - - /// The scan covers line, trailing and nested block comments, while leaving literals alone. - #[test] - fn the_scan_covers_comments_without_reading_literals() { - let h = '#'; - let source = format!( - "// full {h}119\nlet x = 0; // pre-{h}22\n/* outer {h}23 /* nested {h}24 */ tail {h}25 */\nfn f<'a /* issue {h}22 */>() {{}}\nlet normal = \"{h}26\";\nlet escaped = \"\\\\\"{h}27\";\nlet raw = r###\"{h}28\"###;\nlet byte_raw = br#\"{h}29\"#;\nlet character = '{h}';\n// build {h}5875 and opcode {h}117\n// build ({h}5875) and opcode [{h}117]\n// BUILD {h}5875, OpCoDe ({h}117), PATCH [{h}123], ReV {h}456, ReViSiOn {h}789\n" - ); - assert_eq!(tracker_refs(&source), vec![1, 2, 3, 3, 3, 4]); - } - - #[test] - fn the_ratchet_rejects_stale_counts() { - assert!(ratchet_mismatch("fixture.rs", 1, 1).is_none()); - assert!(ratchet_mismatch("fixture.rs", 0, 1).is_some()); - assert!(ratchet_mismatch("fixture.rs", 2, 1).is_some()); - } - - #[test] - fn the_ratchet_rejects_a_missing_allow_listed_file() { - assert!(!ratchet_file_is_missing( - "fixture.rs", - &["fixture.rs".to_owned()] - )); - assert!(ratchet_file_is_missing("fixture.rs", &[])); - } -} - -/// ENFORCEMENT tripwire: five unconditional `dead_code` allowances document the remaining -/// deliberate residues. A conditional `cfg_attr` allowance does not count here. -#[cfg(test)] -pub(crate) mod dead_code_allowance_tripwire { - // Three taxonomy probes, the stacking boundary, and the retained Package API v1 cast_at adapter. - const EXPECTED_UNCONDITIONAL_ALLOWANCES: usize = 5; - - fn unconditional_dead_code_allows(content: &str) -> Vec { - let mut lines = Vec::new(); - for (start, _) in content.match_indices("#[allow(") { - let line_start = content[..start].rfind('\n').map_or(0, |index| index + 1); - if !content[line_start..start].trim().is_empty() { - continue; - } - let Some(end) = content[start..].find(")]") else { - continue; - }; - if content[start..start + end].contains("dead_code") { - lines.push(crate::test_scan::line_of(content, start)); - } - } - lines - } - - #[test] - fn module_dead_code_allowances_stay_at_declared_boundaries() { - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); - let mut files = Vec::new(); - super::character_owned_tripwire::collect_rs_files(&root, &mut files); - let mut found = Vec::new(); - for file in files { - let rel = file - .strip_prefix(&root) - .expect("scanned under module/src") - .to_string_lossy() - .replace('\\', "/"); - let content = std::fs::read_to_string(&file).expect("readable module source"); - found.extend( - unconditional_dead_code_allows(&content) - .into_iter() - .map(|line| format!("{rel}:{line}")), - ); - } - assert_eq!( - found.len(), - EXPECTED_UNCONDITIONAL_ALLOWANCES, - "unconditional dead_code allowances changed:\n {}", - found.join("\n ") - ); - } - - #[test] - fn the_scan_excludes_conditional_allowances() { - let source = - "#[allow(dead_code, reason = \"residue\")]\n#[cfg_attr(test, allow(dead_code))]\n"; - assert_eq!(unconditional_dead_code_allows(source), vec![1]); - } -} - /// ENFORCEMENT tripwire: Core source names no official Package, in code, comments or the /// generated Gateway bindings. Core must build and read the same with no Package installed. #[cfg(test)] @@ -2294,7 +1983,8 @@ pub(crate) mod package_name_tripwire { } assert!( found.is_empty(), - "Core source names an official Package:\n {}\n\nName the capability instead.", + "Invariant: Core source names no official Package.\n\ + Core source names an official Package:\n {}\n\nName the capability instead.", found.join("\n ") ); } From f18aa54892a01e0aa898a6ea0fae2ee85797737a Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 13:40:53 +0200 Subject: [PATCH 05/21] refactor(test-support): move the Rust body scanner into lyracore-test-support `code_of`, `shape_of` and `body_of` now live in `lyracore_test_support::source_scan`. The Package API's `package_test` root re-exports `code_of` and `shape_of` from there under the same names, because official Package tests still call them. The Module keeps only the helpers its Architecture Tests use. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/lyracore-test-support/src/lib.rs | 1 + .../lyracore-test-support/src/source_scan.rs | 102 +++++++++ module/src/package_test.rs | 3 +- module/src/publish_safety.rs | 7 +- module/src/test_scan.rs | 212 +----------------- 5 files changed, 115 insertions(+), 210 deletions(-) create mode 100644 crates/lyracore-test-support/src/source_scan.rs diff --git a/crates/lyracore-test-support/src/lib.rs b/crates/lyracore-test-support/src/lib.rs index 96673f67f..c78d45429 100644 --- a/crates/lyracore-test-support/src/lib.rs +++ b/crates/lyracore-test-support/src/lib.rs @@ -1,4 +1,5 @@ mod module_wasm; +pub mod source_scan; pub use module_wasm::{gateway_binary, module_bytes}; diff --git a/crates/lyracore-test-support/src/source_scan.rs b/crates/lyracore-test-support/src/source_scan.rs new file mode 100644 index 000000000..34d6512af --- /dev/null +++ b/crates/lyracore-test-support/src/source_scan.rs @@ -0,0 +1,102 @@ +//! Rust source-scan primitives for Architecture Tests and the Package API's `package_test` root. + +/// Isolate the brace-matched body that follows the first occurrence of `signature`. Panics if the +/// signature or a balanced `{...}` is missing, because a scan that cannot find its target has lost +/// its pin. +pub fn body_of(src: &str, signature: &str) -> String { + let start = src + .find(signature) + .unwrap_or_else(|| panic!("`{signature}` no longer exists in this source")); + let rest = &src[start..]; + let open = rest.find('{').expect("fn has a body"); + let mut depth = 0i32; + for (i, c) in rest[open..].char_indices() { + match c { + '{' => depth += 1, + '}' => { + depth -= 1; + if depth == 0 { + return rest[open..=open + i].to_string(); + } + } + _ => {} + } + } + panic!("unterminated body for `{signature}`"); +} + +/// Strip a `//` comment from `line`, respecting double-quoted string literals. Raw strings and +/// block comments are not understood. +fn strip_line_comment(line: &str) -> &str { + let bytes = line.as_bytes(); + let mut in_string = false; + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'\\' if in_string => i += 1, + b'"' => in_string = !in_string, + b'/' if !in_string && bytes.get(i + 1) == Some(&b'/') => return &line[..i], + _ => {} + } + i += 1; + } + line +} + +/// [`body_of`] with every comment removed: comment-only lines dropped, trailing comments cut. +pub fn code_of(src: &str, signature: &str) -> String { + body_of(src, signature) + .lines() + .map(strip_line_comment) + .map(str::trim_end) + .filter(|l| !l.trim().is_empty()) + .collect::>() + .join("\n") +} + +/// [`code_of`] with every whitespace run collapsed to one space, for an equality comparison. +pub fn shape_of(src: &str, signature: &str) -> String { + code_of(src, signature) + .split_whitespace() + .collect::>() + .join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn code_of_strips_a_trailing_comment_and_keeps_the_live_half() { + let src = "fn f() {\n let _ = ctx; // bump(ctx, g);\n // bump in prose\n}"; + let code = code_of(src, "fn f("); + assert!(!code.contains("bump"), "{code}"); + assert!(code.contains("let _ = ctx;"), "{code}"); + } + + #[test] + fn code_of_keeps_a_slash_slash_inside_a_string_literal() { + let src = "fn f() {\n Err(\"see https://example.com/docs\")?;\n}"; + assert!(code_of(src, "fn f(").contains("https://example.com/docs")); + } + + #[test] + fn code_of_handles_an_escaped_quote_inside_a_string_literal() { + let src = "fn f() {\n let s = \"a \\\"q\\\" // kept\";\n let t = 1; // cut\n}"; + let code = code_of(src, "fn f("); + assert!(code.contains("// kept"), "{code}"); + assert!(!code.contains("cut"), "{code}"); + } + + #[test] + fn shape_of_collapses_whitespace_for_exact_equality() { + let src = "fn f() {\n let x =\n 1;\n}"; + assert_eq!(shape_of(src, "fn f("), "{ let x = 1; }"); + } + + #[test] + #[should_panic(expected = "no longer exists")] + fn body_of_panics_when_the_signature_is_gone() { + body_of("fn g() {}", "fn f("); + } +} diff --git a/module/src/package_test.rs b/module/src/package_test.rs index 03ebb1aa9..50006bb92 100644 --- a/module/src/package_test.rs +++ b/module/src/package_test.rs @@ -6,7 +6,8 @@ use crate::runtime_script::{ask_event, EffectSink, RuntimeScriptHost, ScriptEvent}; pub(crate) use crate::runtime_script::{EntityView, RuntimeScript}; -pub(crate) use crate::test_scan::{code_of, read_scanned, shape_of}; +pub(crate) use crate::test_scan::read_scanned; +pub(crate) use lyracore_test_support::source_scan::{code_of, shape_of}; /// Run `scripts` in order for one event on a fresh Runtime Script Host and return the Script /// Answer, read as `script_binding::ask` reads it. Staged Effects are discarded. Any Script diff --git a/module/src/publish_safety.rs b/module/src/publish_safety.rs index 601c2f04b..fafecf5af 100644 --- a/module/src/publish_safety.rs +++ b/module/src/publish_safety.rs @@ -32,8 +32,7 @@ const DESTRUCTIVE_LONG_FLAGS: &[&str] = &["--clear-database", "--delete-data"]; /// Strip a `#`-comment from one line of shell (or Python), respecting single- and double-quoted /// strings so a `#` inside one survives. /// -/// `test_scan::strip_line_comment` understands Rust's `//` and is no use here. Two shell rules this -/// must get right, both load-bearing on the real files: +/// Two shell rules this must get right, both load-bearing on the real files: /// - `#` only opens a comment at the start of a WORD. `[ ${#dbs[@]} -eq 0 ]` in /// the sanctioned deploy script is code, not a comment, and a naive scanner truncates that script /// at line 30 — losing the publish line entirely and passing vacuously. @@ -124,8 +123,8 @@ fn destructive_publish_lines(src: &str) -> Vec<(usize, String)> { /// `src` with comments gone and every whitespace run collapsed to one space, so a block of shell /// can be compared for EQUALITY (or exact containment) instead of by loose `.contains()` on raw -/// text. Same rationale as `test_scan::shape_of`, which is for Rust bodies and cannot be reused: -/// equality is what distinguishes "this guard IS exactly this" from "these words appear somewhere". +/// text. Equality is what distinguishes "this guard IS exactly this" from "these words appear +/// somewhere". fn shell_shape(src: &str) -> String { src.lines() .map(strip_shell_comment) diff --git a/module/src/test_scan.rs b/module/src/test_scan.rs index 6395776bb..e7b4a5e69 100644 --- a/module/src/test_scan.rs +++ b/module/src/test_scan.rs @@ -1,100 +1,12 @@ -//! Shared source-scan primitives for this crate's `#[cfg(test)]` tripwires. Test-only (there is no -//! `ReducerContext` harness in this crate by design — playbook §7 — so a scan of the reducer's own -//! source text is how a chokepoint's PRESENCE gets pinned; the SENSE of a decision should be pulled -//! into a pure function and asserted on directly instead, wherever that is reachable — see -//! `helpers::gate_in_transit` for the pattern). +//! Source-scan primitives for this crate's Architecture Tests (`tripwires.rs`, +//! `publish_safety.rs`) and the Package API's `package_test` root. Test-only. //! -//! This used to be six near-identical copies (`auth.rs`, `chat.rs`, `group.rs`, -//! `instance.rs`, `world.rs`, `transfer.rs`), and they had already drifted — two of them stripped a -//! `// trailing comment` off an otherwise-live line, the other four only dropped a line that was -//! ENTIRELY a comment. A needle planted in a trailing comment (`let _ = ctx; // the real call used -//! to be here`) satisfied every `.contains()` scan built on the weak four, while doing nothing. One -//! implementation, hardened, used everywhere in this crate. +//! The Rust body scanners (`code_of`, `shape_of`) live in `lyracore_test_support::source_scan`. use std::io::Write; -/// Isolate a fn (or struct/const/etc.) body by brace-matching from the first byte offset where -/// `signature` appears. Panics loudly — never silently matches nothing — if the signature or a -/// balanced `{...}` cannot be found, because a scan that can't find its target has lost its -/// pin, not passed it. -pub(crate) fn body_of(src: &str, signature: &str) -> String { - let start = src - .find(signature) - .unwrap_or_else(|| panic!("`{signature}` no longer exists in this source")); - let rest = &src[start..]; - let open = rest.find('{').expect("fn has a body"); - let mut depth = 0i32; - for (i, c) in rest[open..].char_indices() { - match c { - '{' => depth += 1, - '}' => { - depth -= 1; - if depth == 0 { - return rest[open..=open + i].to_string(); - } - } - _ => {} - } - } - panic!("unterminated body for `{signature}`"); -} - -/// Strip a Rust line comment from `line`, respecting simple double-quoted string literals so a -/// `//` inside one (an error message containing a URL, say) is never mistaken for a comment start. -/// Does not understand raw strings or block comments — nothing this crate scans uses either, and a -/// miss here fails LOUD (the caller's assertion breaks), never silently. -fn strip_line_comment(line: &str) -> &str { - let bytes = line.as_bytes(); - let mut in_string = false; - let mut i = 0; - while i < bytes.len() { - match bytes[i] { - b'\\' if in_string => i += 1, // skip whatever the backslash escapes, incl. `\"` - b'"' => in_string = !in_string, - b'/' if !in_string && bytes.get(i + 1) == Some(&b'/') => return &line[..i], - _ => {} - } - i += 1; - } - line -} - -/// [`body_of`] with every comment gone — a whole comment-only line dropped entirely, a trailing -/// comment on an otherwise-live line truncated at its `//`. Never `.contains()` a raw `body_of` -/// result; always go through this (or [`shape_of`], for a still-stronger equality check). -pub(crate) fn code_of(src: &str, signature: &str) -> String { - body_of(src, signature) - .lines() - .map(strip_line_comment) - .map(str::trim_end) - .filter(|l| !l.trim().is_empty()) - .collect::>() - .join("\n") -} - -/// [`code_of`] with every whitespace run collapsed to one space, so a body can be compared for -/// EQUALITY (or an exact leading slice, for a larger function) instead of `.contains()`. Equality -/// is what actually distinguishes "this body IS exactly this" from "this text appears somewhere in -/// this body" — the distinction a `.contains()` scan can never make, and the one every comment- or -/// dead-branch-based defeat in this codebase's history has slipped through. -pub(crate) fn shape_of(src: &str, signature: &str) -> String { - code_of(src, signature) - .split_whitespace() - .collect::>() - .join(" ") -} - -// ================================================================================================ -// Shared engine for a "raw table read outside its chokepoint" tripwire. -// ================================================================================================ -// -// `partition_discipline_tripwire::raw_scans` (spatial whole-table `.iter()`/`.count()`) and -// `character_fence_tripwire::raw_lookups` (raw `game_character` point lookups) used to be ~70-line -// near-clones of this — identical bound-handle walk-back, handle dedup and comment-line filtering -// — and had already drifted from each other in small ways neither review caught. The exact drift -// pattern consolidated once already, one layer up (`body_of`/`code_of`, above). One -// engine now; the two callers differ only in which accessor(s) they watch and what counts as -// "opens a read" they care about. +// The engine behind the spatial-scan and transfer-fence Architecture Tests. The two callers differ +// only in which accessors they watch and what counts as opening a read. /// `byte_idx` sits on a `//` line, i.e. it is prose rather than code. pub(crate) fn on_comment_line(content: &str, byte_idx: usize) -> bool { @@ -104,7 +16,7 @@ pub(crate) fn on_comment_line(content: &str, byte_idx: usize) -> bool { /// `byte_idx` sits inside an ordinary double-quoted string literal on its line. Source scanners /// must not treat assertion needles as live table reads. -pub(crate) fn in_string_literal(content: &str, byte_idx: usize) -> bool { +fn in_string_literal(content: &str, byte_idx: usize) -> bool { let line_start = content[..byte_idx].rfind('\n').map(|i| i + 1).unwrap_or(0); let bytes = &content.as_bytes()[line_start..byte_idx]; let mut in_string = false; @@ -127,7 +39,7 @@ pub(crate) fn line_of(content: &str, byte_idx: usize) -> usize { /// `name` at `byte_idx` is a standalone identifier, not a substring of a longer one and not a /// field access (`self.entities` is somebody else's business). -pub(crate) fn is_standalone_ident(content: &str, byte_idx: usize, name: &str) -> bool { +fn is_standalone_ident(content: &str, byte_idx: usize, name: &str) -> bool { let before_ok = content[..byte_idx] .chars() .next_back() @@ -236,57 +148,6 @@ pub(crate) fn raw_table_reads( // skip ONLY when the whole optional directory is absent — if the directory is there and the // named file is not, that is a PATH TYPO, and the tripwire must fail on it. -/// Concatenate every file in `module/src/debug/` (the `#[cfg(feature = "debug_reducers")]` -/// directory module split out of the former single `debug.rs`) into one blob, so a text-scan -/// tripwire that used to `include_str!("debug.rs")` keeps seeing every debug reducer regardless of -/// which of the seven files it landed in. Directory-read order (unspecified) is fine — every caller -/// only substring/signature-searches, never anchors on cross-file position or file boundaries. -pub(crate) fn debug_dir_src() -> String { - let dir = repo_root().join("module/src/debug"); - let mut out = String::new(); - for entry in - std::fs::read_dir(&dir).unwrap_or_else(|e| panic!("cannot read {}: {e}", dir.display())) - { - let path = entry.expect("readable dir entry").path(); - if path.extension().map(|e| e == "rs").unwrap_or(false) { - out.push_str( - &std::fs::read_to_string(&path) - .unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display())), - ); - out.push('\n'); - } - } - out -} - -/// Every `.rs` file under `module/src`, as repo-relative paths, sorted. For a CRATE-WIDE scan — the -/// "this call exists in exactly one place" shape, which a per-file `include_str!` cannot express -/// (a new file is exactly where a second call site would appear). -pub(crate) fn module_sources() -> Vec { - fn walk(dir: &std::path::Path, root: &std::path::Path, out: &mut Vec) { - let entries = - std::fs::read_dir(dir).unwrap_or_else(|e| panic!("cannot read {}: {e}", dir.display())); - for entry in entries { - let path = entry.expect("readable dir entry").path(); - if path.is_dir() { - walk(&path, root, out); - } else if path.extension().is_some_and(|e| e == "rs") { - let rel = path - .strip_prefix(root) - .expect("walked from the repo root") - .to_string_lossy() - .replace('\\', "/"); - out.push(rel); - } - } - } - let root = repo_root(); - let mut out = Vec::new(); - walk(&root.join("module/src"), &root, &mut out); - out.sort(); - out -} - /// The repo root: `module/`'s parent. pub(crate) fn repo_root() -> std::path::PathBuf { std::path::Path::new(env!("CARGO_MANIFEST_DIR")) @@ -346,59 +207,6 @@ pub(crate) fn read_scanned(rel: &str) -> Option { mod tests { use super::*; - /// The fixture the trailing-comment defeat looked like for real (round-1 review): a `let - /// _ = ctx;` no-op with the real call demoted to a trailing comment. The weak four-file version - /// of `code_of` (`.filter(|l| !l.trim_start().starts_with("//"))`) left that comment's text - /// intact, so `.contains("bump_guid_high_water")` still passed. This must now be gone. - #[test] - fn code_of_strips_a_trailing_comment_not_just_a_leading_one() { - let src = "fn f() {\n let _ = 1;\n let _ = ctx; // crate::auth::bump_guid_high_water(ctx, g);\n // a whole-line comment naming it too: bump_guid_high_water\n}"; - let code = code_of(src, "fn f("); - assert!( - !code.contains("bump_guid_high_water"), - "a needle sitting in a trailing OR a leading comment must not survive code_of. Got:\n{code}" - ); - // The live code on the trailing-comment line must survive — only the comment is cut. - assert!( - code.contains("let _ = ctx;"), - "code_of must not eat the live half of a line. Got:\n{code}" - ); - } - - /// The other half of the same acceptance criterion: a `//` that is part of a STRING LITERAL — - /// an error message quoting a URL, say — must not be mistaken for a comment start and truncate - /// real code. - #[test] - fn code_of_does_not_treat_a_slash_slash_inside_a_string_literal_as_a_comment() { - let src = r#"fn f() { - Err("see https://example.com/docs for details")?; -}"#; - let code = code_of(src, "fn f("); - assert!( - code.contains("https://example.com/docs"), - "a `//` inside a string literal must survive code_of intact — it is not a comment. Got:\n{code}" - ); - } - - /// A comment containing an escaped quote inside its OWN string-literal-shaped text must not - /// desync the in-string tracker for the rest of the line — degenerate but cheap to guard. - #[test] - fn code_of_handles_an_escaped_quote_inside_a_string_literal() { - let src = r#"fn f() { - let s = "a \"quoted\" word // not a comment"; - let t = 1; // this IS a comment -}"#; - let code = code_of(src, "fn f("); - assert!( - code.contains(r#"a \"quoted\" word // not a comment"#), - "escaped quotes desynced the string tracker. Got:\n{code}" - ); - assert!( - !code.contains("this IS a comment"), - "the real trailing comment on the next line must still be stripped. Got:\n{code}" - ); - } - #[test] fn raw_table_reads_ignores_a_table_scan_named_inside_a_string_literal() { let src = r#"fn f() { @@ -413,12 +221,6 @@ mod tests { ); } - #[test] - fn shape_of_collapses_whitespace_for_exact_equality() { - let src = "fn f() {\n let x =\n 1;\n}"; - assert_eq!(shape_of(src, "fn f("), "{ let x = 1; }"); - } - // ---- the optional-tree resolver ------------------------------------------------------------ /// A drop-in package is optional one PACKAGE at a time; the private-only tooling trees are From b2e64bcf1dda8f1c1dc5371897af7cc319420493 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 13:40:53 +0200 Subject: [PATCH 06/21] refactor(gateway): deny panic constructs at untrusted-input boundaries with clippy The logon handshake, the addon and auth-session decoders and the configuration parser deny `unwrap`, `expect`, `panic!`, `unreachable!`, `todo!` and `unimplemented!` outside tests. This replaces the source scan of the same four files, and the message is one clippy line. Co-Authored-By: Claude Opus 5.5 (1M context) --- gateway/src/codec/addon.rs | 13 +++++++++++++ gateway/src/codec/auth_session.rs | 13 +++++++++++++ gateway/src/config.rs | 13 +++++++++++++ gateway/src/logon/mod.rs | 13 +++++++++++++ 4 files changed, 52 insertions(+) diff --git a/gateway/src/codec/addon.rs b/gateway/src/codec/addon.rs index 9c718889c..29a6c0702 100644 --- a/gateway/src/codec/addon.rs +++ b/gateway/src/codec/addon.rs @@ -16,6 +16,19 @@ //! /|"`. v1 implements the single-part fast path only — a multi-part //! message logs and drops (payloads that don't fit ~230 bytes are a design smell at this layer). +// Untrusted input arrives here: a malformed packet or env var must return an error, never unwind. +#![cfg_attr( + not(test), + deny( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::unreachable, + clippy::todo, + clippy::unimplemented + ) +)] + /// The addon language value `SendAddonMessage` stamps (`LANG_ADDON`). pub const LANG_ADDON: u32 = 0xFFFF_FFFF; /// Our bridge prefix — the addon sends/filters on exactly this. diff --git a/gateway/src/codec/auth_session.rs b/gateway/src/codec/auth_session.rs index b2e6ccf16..8864ab9e4 100644 --- a/gateway/src/codec/auth_session.rs +++ b/gateway/src/codec/auth_session.rs @@ -7,6 +7,19 @@ //! `build: u32`, `server_id: u32`, `username: CString`, `client_seed: u32`, `client_proof: u8[20]`, //! then the compressed addon list. +// Untrusted input arrives here: a malformed packet or env var must return an error, never unwind. +#![cfg_attr( + not(test), + deny( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::unreachable, + clippy::todo, + clippy::unimplemented + ) +)] + use std::io::{self, Read}; /// The opcode of the one plaintext client frame in the world handshake. diff --git a/gateway/src/config.rs b/gateway/src/config.rs index a0bdc5b12..0b78c5dca 100644 --- a/gateway/src/config.rs +++ b/gateway/src/config.rs @@ -1,5 +1,18 @@ //! Gateway configuration. All operational, none of it game state. +// Untrusted input arrives here: a malformed packet or env var must return an error, never unwind. +#![cfg_attr( + not(test), + deny( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::unreachable, + clippy::todo, + clippy::unimplemented + ) +)] + use crate::accept::BlockingTaskCapacity; #[derive(Clone, Debug)] diff --git a/gateway/src/logon/mod.rs b/gateway/src/logon/mod.rs index abe0499f9..d8d65b10a 100644 --- a/gateway/src/logon/mod.rs +++ b/gateway/src/logon/mod.rs @@ -9,6 +9,19 @@ //! The only state this touches is the `game_account` read (salt/verifier) and the session write //! (K) — both via `LogonStore`. Everything else is per-connection handshake scratch. +// Untrusted input arrives here: a malformed packet or env var must return an error, never unwind. +#![cfg_attr( + not(test), + deny( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::unreachable, + clippy::todo, + clippy::unimplemented + ) +)] + pub mod limiter; use crate::accept::{classify_accept_error, AcceptBackoff, AcceptOutcome}; From 954d65a913dc792d52a8a6c4def45ae688e4572d Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 13:40:53 +0200 Subject: [PATCH 07/21] test(gateway): prove provision writes the account to both databases A durable test runs `lyracore-gateway provision` against a standalone with a World Shard and a distinct Realm-core, then reads `game_account` on both. It replaces the scan of `provision`'s source and runs in the durable workflow. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/module-durable.yml | 1 + gateway/tests/provision_cli.rs | 50 ++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/.github/workflows/module-durable.yml b/.github/workflows/module-durable.yml index e93425936..97b5debab 100644 --- a/.github/workflows/module-durable.yml +++ b/.github/workflows/module-durable.yml @@ -100,6 +100,7 @@ jobs: cargo test -p lyracore-gateway --bin lyracore-gateway stdb::subscriptions::roster_relay_durable_tests::a_roster_change_made_only_on_realm_core_reaches_every_world_shard_mirror -- --ignored --exact cargo test -p lyracore-gateway --bin lyracore-gateway stdb::subscriptions::meeting_stone_durable_tests::seekers_on_two_world_shards_form_one_party_that_reaches_every_mirror -- --ignored --exact cargo test -p lyracore-gateway --bin lyracore-gateway stdb::armor::tests::resistance_projection_adds_property_effects_only_while_the_item_is_worn_and_unbroken -- --ignored --exact + cargo test -p lyracore-gateway --test provision_cli provision_writes_the_account_to_the_world_shard_and_realm_core -- --ignored --exact # No Package registers a client command here, so the Module's fixture command carries it. - name: Run the cross-Shard command transport tests diff --git a/gateway/tests/provision_cli.rs b/gateway/tests/provision_cli.rs index 1a08ce286..e6a759bd7 100644 --- a/gateway/tests/provision_cli.rs +++ b/gateway/tests/provision_cli.rs @@ -103,3 +103,53 @@ fn valid_password_stdin_reaches_provisioning_without_disclosing_the_password() { assert!(!stderr.contains("functional-test-token")); assert!(!stderr.contains("invalid password")); } + +/// The SRP6 challenge is answered from Realm-core, and characters belong to the World Shard's +/// account row, so a provisioned account must exist on both databases. +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn provision_writes_the_account_to_the_world_shard_and_realm_core() { + use lyracore_test_support::{module_bytes, Standalone}; + const REALM: &str = "provision-realm"; + + let mut standalone = Standalone::start_persistent("provision-world"); + standalone.publish_module(); + standalone.publish_named_module_bytes(REALM, module_bytes()); + standalone.assert_call("claim_operator", &[]); + standalone.assert_call_database(REALM, "claim_operator", &[]); + + let mut child = Command::new(env!("CARGO_BIN_EXE_lyracore-gateway")) + .args(["provision", "NEWHERO", "--password-stdin"]) + .env("LYRACORE_COORDINATOR_TOKEN", standalone.owner_token()) + .env("LYRACORE_DATABASE", standalone.shard_name()) + .env("LYRACORE_SPACETIMEDB_URL", standalone.server()) + .env("LYRACORE_REALM_CORE", REALM) + .env_remove("LYRACORE_SHARD_MAP") + .env_remove("LYRACORE_SHARD_MAP_FILE") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn gateway"); + child + .stdin + .take() + .unwrap() + .write_all(b"hero pass\n") + .unwrap(); + let output = child.wait_with_output().expect("wait for gateway"); + assert!( + output.status.success(), + "provision failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let query = "SELECT username FROM game_account WHERE username = 'NEWHERO'"; + for database in [standalone.shard_name().to_owned(), REALM.to_owned()] { + assert_eq!( + standalone.query_database_rows(&database, query).len(), + 1, + "{database} has no NEWHERO account" + ); + } +} From d30492a2bd5236aaaf4b7983b5c12dccc52aa1f5 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 14:31:33 +0200 Subject: [PATCH 08/21] docs: describe current behavior and move long module guides Remove work-item and review history from source comments. Retain the current Transfer, Realm authority, private-recipient, and tick contracts, and move long module guides to docs. Co-Authored-By: GPT-6.1 Sol via Codex --- .../src/bin/lyracore-delta-check.rs | 8 - crates/lyracore-package-delta/src/lib.rs | 129 +----- .../tests/script_artifact.rs | 2 - .../tests/script_refusals.rs | 3 - crates/lyracore-shared/src/constants.rs | 20 +- crates/lyracore-shared/src/group.rs | 12 +- crates/lyracore-shared/src/instance.rs | 31 +- crates/lyracore-shared/src/loot_roll.rs | 54 +-- crates/lyracore-shared/src/nav.rs | 22 +- crates/lyracore-shared/src/quest.rs | 12 +- crates/lyracore-shared/src/region.rs | 29 +- crates/lyracore-shared/src/spatial.rs | 23 +- crates/lyracore-shared/src/terrain.rs | 8 - crates/lyracore-shared/src/trade.rs | 24 +- crates/lyracore-shared/src/values_mask.rs | 8 +- crates/lyracore-shared/src/vmap.rs | 30 +- docs/accept-policy.md | 25 ++ docs/actor-verbs.md | 57 +++ docs/character-transfer.md | 51 +++ docs/instance-lifecycle.md | 35 ++ docs/module-build.md | 45 ++ docs/package-delta-format.md | 128 ++++++ docs/realm-loot-routing.md | 25 ++ docs/runtime-script-host.md | 63 +++ gateway/src/accept.rs | 75 +--- gateway/src/codec/addon.rs | 8 +- gateway/src/codec/char.rs | 8 +- gateway/src/codec/combat.rs | 5 - gateway/src/codec/entity.rs | 20 +- gateway/src/codec/gameobject.rs | 17 +- gateway/src/codec/loot.rs | 7 - gateway/src/codec/member_stats.rs | 7 - gateway/src/codec/movement.rs | 12 +- gateway/src/codec/quest.rs | 1 - gateway/src/codec/tests.rs | 23 +- gateway/src/codec/update_mask.rs | 9 +- gateway/src/codec/values.rs | 28 +- gateway/src/config.rs | 77 +--- gateway/src/fd_limit.rs | 32 -- gateway/src/load_sample.rs | 52 +-- gateway/src/logon/mod.rs | 9 +- gateway/src/main.rs | 15 +- gateway/src/realm_core.rs | 220 ++-------- gateway/src/stdb/aoi.rs | 11 +- gateway/src/stdb/armor.rs | 12 +- gateway/src/stdb/connection.rs | 101 +---- gateway/src/stdb/reads/account.rs | 6 +- gateway/src/stdb/reads/auction.rs | 2 - gateway/src/stdb/reads/mail.rs | 3 +- gateway/src/stdb/reads/npc_vendor.rs | 5 - gateway/src/stdb/reads/quest.rs | 8 +- gateway/src/stdb/reducers.rs | 41 +- gateway/src/stdb/subscriptions.rs | 121 ++---- gateway/src/stdb/world_index.rs | 67 +-- gateway/src/stdb/world_store.rs | 27 +- gateway/src/stdb/world_view.rs | 50 +-- .../stdb/world_view/cell_audience_tests.rs | 5 - gateway/src/world/coalesce.rs | 8 - gateway/src/world/handlers/char.rs | 15 +- gateway/src/world/handlers/combat.rs | 2 +- gateway/src/world/handlers/guild.rs | 12 - gateway/src/world/handlers/query.rs | 12 +- gateway/src/world/handlers/trade.rs | 8 +- gateway/src/world/handlers/trainer.rs | 8 +- gateway/src/world/login_queue.rs | 42 +- gateway/src/world/loot.rs | 90 +--- gateway/src/world/loot_tests.rs | 2 - gateway/src/world/mail_tests.rs | 5 - gateway/src/world/member_stats_tests.rs | 2 - gateway/src/world/mod.rs | 49 +-- gateway/src/world/packet_lint.rs | 2 +- gateway/src/world/party.rs | 13 - gateway/src/world/party_tests.rs | 94 +--- gateway/src/world/presence.rs | 4 - gateway/src/world/shard_routing_tests.rs | 15 +- gateway/src/world/social.rs | 3 +- gateway/src/world/store.rs | 29 +- gateway/src/world/tests.rs | 101 ++--- gateway/src/world/trade_tests.rs | 18 +- gateway/src/world/transfer.rs | 38 +- gateway/src/world/transfer_tests.rs | 47 +- gateway/src/world/wire_corruption_tests.rs | 14 - gateway/tests/provision_cli.rs | 9 +- gateway/tests/schema_parity.rs | 8 +- importer/src/collision.rs | 2 +- importer/src/dbc.rs | 56 +-- importer/src/go_model.rs | 2 - importer/src/main.rs | 406 +++++++----------- importer/src/nav.rs | 8 +- importer/src/pack_client.rs | 1 - importer/src/package_delta.rs | 32 -- importer/src/spell.rs | 128 ++---- importer/src/talent.rs | 15 +- importer/src/terrain.rs | 30 +- importer/src/vmap.rs | 73 +--- module/build.rs | 89 +--- module/src/action_bar.rs | 4 +- module/src/actor.rs | 68 +-- module/src/auction.rs | 52 +-- module/src/auth.rs | 45 +- module/src/bridge.rs | 8 +- module/src/character.rs | 34 +- module/src/chat.rs | 1 - module/src/combat/death.rs | 16 +- module/src/combat/engage.rs | 26 +- module/src/combat/folds.rs | 5 - module/src/combat/swing.rs | 73 +--- module/src/combat/tables.rs | 18 - module/src/config.rs | 33 +- module/src/corpse.rs | 6 +- module/src/creatures/ai.rs | 88 +--- module/src/creatures/cycle/harness.rs | 4 - module/src/creatures/cycle/mod.rs | 2 - module/src/creatures/eventai/engine.rs | 5 - module/src/creatures/pet.rs | 9 +- module/src/creatures/spawn.rs | 89 +--- module/src/creatures/tick/lifecycle.rs | 23 +- module/src/creatures/tick/mod.rs | 83 ++-- module/src/debug/encounter.rs | 2 +- module/src/debug/fingerprint.rs | 6 - module/src/debug/instance.rs | 43 +- module/src/debug/mod.rs | 74 +--- module/src/encounter.rs | 70 +-- module/src/exploration.rs | 2 +- module/src/gameobject.rs | 124 ++---- module/src/gc.rs | 4 +- module/src/gm.rs | 14 +- module/src/graveyard.rs | 90 +--- module/src/group.rs | 69 +-- module/src/guild/fee.rs | 12 +- module/src/gw.rs | 27 +- module/src/helpers.rs | 73 +--- module/src/hooks.rs | 22 +- module/src/import_meta.rs | 2 +- module/src/instance.rs | 172 +------- module/src/items/economy.rs | 7 - module/src/items/inventory.rs | 19 +- module/src/items/mod.rs | 9 - module/src/items/ops.rs | 29 +- module/src/items/rules.rs | 11 - module/src/items/tables.rs | 23 +- module/src/lib.rs | 32 +- module/src/load.rs | 6 +- module/src/loot/mod.rs | 133 ++---- module/src/loot/rolls.rs | 72 +--- module/src/mail_escrow.rs | 6 +- module/src/mail_text.rs | 5 - module/src/motion.rs | 13 +- module/src/mount.rs | 10 - module/src/nav.rs | 4 +- module/src/operations.rs | 19 +- module/src/professions.rs | 24 +- module/src/quest.rs | 70 ++- module/src/realm_core.rs | 2 +- module/src/region.rs | 35 +- module/src/reputation.rs | 2 +- module/src/runtime_script.rs | 72 +--- module/src/seed.rs | 70 ++- module/src/seed/fixtures.rs | 21 +- module/src/skill.rs | 36 +- module/src/skilldata.rs | 23 +- module/src/spell/cast/resolve.rs | 22 +- module/src/spell/cast/targeting.rs | 79 +--- module/src/spell/control.rs | 11 +- module/src/spell/effects.rs | 22 +- module/src/spell/math.rs | 17 - module/src/spell/mod.rs | 19 +- module/src/spell/proc.rs | 10 - module/src/spell/scheduler.rs | 28 +- module/src/spell/spellbook.rs | 30 +- module/src/spell/stacking.rs | 54 +-- module/src/spell/tables.rs | 48 +-- module/src/spell/taxonomy.rs | 13 +- module/src/spell/tests.rs | 14 +- module/src/stats.rs | 15 - module/src/talent.rs | 42 +- module/src/taxi.rs | 2 +- module/src/terrain.rs | 16 +- module/src/threat.rs | 7 +- module/src/trade.rs | 16 +- module/src/trainer.rs | 47 +- module/src/transfer/harness.rs | 165 +------ module/src/transfer/mod.rs | 254 +---------- module/src/transfer/tests.rs | 77 +--- module/src/transfer/transport.rs | 128 +----- module/src/tripwires.rs | 80 +--- module/src/vmap.rs | 11 +- module/src/weather.rs | 3 - module/src/world.rs | 161 ++----- module/src/xp.rs | 6 +- module/tests/auction_cancel.rs | 6 +- 191 files changed, 1625 insertions(+), 5449 deletions(-) create mode 100644 docs/accept-policy.md create mode 100644 docs/actor-verbs.md create mode 100644 docs/character-transfer.md create mode 100644 docs/instance-lifecycle.md create mode 100644 docs/module-build.md create mode 100644 docs/package-delta-format.md create mode 100644 docs/realm-loot-routing.md create mode 100644 docs/runtime-script-host.md diff --git a/crates/lyracore-package-delta/src/bin/lyracore-delta-check.rs b/crates/lyracore-package-delta/src/bin/lyracore-delta-check.rs index b502414a4..52d76e205 100644 --- a/crates/lyracore-package-delta/src/bin/lyracore-delta-check.rs +++ b/crates/lyracore-package-delta/src/bin/lyracore-delta-check.rs @@ -115,14 +115,6 @@ enum Artifact { Script(ScriptArtifact), } -/// Prints [`HOOK_EVENT_NAMES`], one event per line, in catalogue order, and exits. -/// -/// `--print-events` must be the only argument: this mode reads no artifact, no other file and the -/// clock never enters it, so the output is byte-stable across machines and runs. A future Package's -/// TypeScript authoring surface is generated from this output, so it stays the one place outside the -/// Module's own build that knows the Event Binding catalogue — `HOOK_EVENT_NAMES` here is already a -/// second copy of `module/build.rs`'s `HOOK_EVENTS`; this prints that copy rather than hand-writing -/// a third. fn print_events(args: &[String]) -> ExitCode { if args.len() != 1 { eprintln!( diff --git a/crates/lyracore-package-delta/src/lib.rs b/crates/lyracore-package-delta/src/lib.rs index 23a459ef6..e6c0d0d3c 100644 --- a/crates/lyracore-package-delta/src/lib.rs +++ b/crates/lyracore-package-delta/src/lib.rs @@ -1,131 +1,4 @@ -//! The **Package Delta** artifact and the claim conflict tracer. -//! -//! A Package Delta is the durable record of what one Package changes in the spell tables. It names -//! the Package, the source it was generated from, and one claim per row: the table, the typed -//! primary key, the operation, and the columns the Package sets there. A base import replaces whole -//! data families, so a Package's row edits have to be a replayable artifact rather than a one-shot -//! edit that the next import silently reverts. -//! -//! This library is pure and deterministic. It reads no files, keeps no state, reads no clock, and -//! writes nothing. It parses, canonicalizes, and traces; applying a delta to a shard is a separate -//! concern. The crate also ships `lyracore-delta-check`, a command that reads named artifact files -//! and prints the plan they form — the IO lives there, outside everything a caller links against. -//! -//! # The artifact -//! -//! ```json -//! { -//! "version": 1, -//! "package": "example.sharper-fireballs", -//! "source_hash": "0000000000000000000000000000000000000000000000000000000000000000", -//! "claims": [ -//! { -//! "table": "game_spell", -//! "key": { "spell_id": 133 }, -//! "operation": "update", -//! "fields": { -//! "cooldown_ms": { "type": "u32", "value": 1500 }, -//! "family_flags": { "type": "u64", "value": "18446744073709551615" } -//! } -//! }, -//! { -//! "table": "game_spell_effect", -//! "key": { "spell_id": 133, "effect_index": 0 }, -//! "operation": "update", -//! "fields": { "base_points": { "type": "i32", "value": 120 } } -//! } -//! ] -//! } -//! ``` -//! -//! Every value states its type. An unsigned 64-bit value is a decimal **string**, because a JSON -//! number loses precision above 2^53 and would come back silently wrong. A `game_spell_effect` key -//! names the spell and the effect index; the packed durable key -//! (`(spell_id << 2) | effect_index`) is derived, never authored. -//! -//! # What it refuses -//! -//! Unknown versions, tables, columns, type tags and malformed keys fail at [`PackageDelta::parse`], -//! before an applier sees the artifact. So does row deletion: uninstalling a Package replays the -//! affected families without its claims, and there is no pre-image a delete could be undone from. -//! An `update` names only the columns it changes; an `insert` carries the whole row. -//! -//! # What it traces -//! -//! [`trace`] merges the claims of several Packages. Different columns of one row merge. The same -//! column, or two inserts at one primary key, is a [`ClaimConflict`] naming both Packages and the -//! exact claim. There are no priority numbers: the tracer reports that a human has to choose. -//! -//! # Import Families -//! -//! Every claimable table belongs to one Import Family, the unit a base import clears and reloads -//! and the unit an apply runs for. [`Table`] is one closed enum with its variants grouped by -//! family, and [`Table::family`] names the owner, so an applier called for one family can tell that -//! a claim belongs to the import it is running. This build's catalogue is the spell, item, quest, -//! loot, cast, trainer, gossip, globals, spellmeta, creatures, gameobjects and creature-ai -//! families. `game_creature_quest`/`game_gameobject_quest` (quest givers) and -//! `game_creature_loot`/`game_npc_vendor` (creature-scoped loot and vendor stock) are out of it. -//! So is `game_start_item`, which two importer passes own between them, `game_creature_waypoint`, -//! which carries no map to route by, and the two gameobject pool tables, which no base import -//! writes. So are the `EventAI` definition tables, whose rules are a nested payload no scalar column -//! shape can state: a Package Delta claims typed rows, never a script blob. See -//! [`Table::columns`], [`LOOT_FAMILY`], [`GLOBALS_FAMILY`], [`CREATURE_FAMILY`], -//! [`GAMEOBJECT_FAMILY`] and [`CREATURE_AI_FAMILY`] for why. -//! -//! # Spatial claims and routing -//! -//! Two tables are SPATIAL: `game_creature_spawn` and `game_gameobject`. Their claims carry the map -//! in the primary key rather than in a column, and [`PrimaryKey::map_id`] reads it. A spatial claim -//! reaches only the Shards whose World Import Scope owns that map; the importer applies that filter -//! with the scope it already builds for the base import, so there is one routing concept and this -//! crate only has to state which map a row sits on. Every other table is a global catalogue every -//! Shard loads whole. -//! -//! A spawn's durable guid is derived from the key, never authored: `game_creature_spawn` packs its -//! creature template and spawn identifier into one guid, `game_gameobject` its spawn identifier -//! alone. The map is not part of either guid, which is why it is identity for routing and not a -//! claimable column: a Package cannot move a placed row onto a map another Shard owns. -//! -//! Most tables permit insert and partial update. Six are update-only, because their primary key -//! names something no Package may invent: a creature template (`game_creature_cast`, -//! `game_gossip_menu`), a race, class or level the client fixes (`game_class_level_stats`, -//! `game_level_stats`, `game_start_position`), or an `AreaTrigger.dbc` trigger id -//! (`game_areatrigger_teleport`). [`DeltaError::InsertNotSupported`] refuses every insert on those -//! at parse time, regardless of identifier. -//! -//! # Identifier policy -//! -//! An inserted row must sit in its family's Package identifier band, which no client and no import -//! can reach — for spells, [`PACKAGE_SPELL_ID_FLOOR`]..=[`PACKAGE_SPELL_ID_CEIL`]; for items, -//! [`PACKAGE_ITEM_ID_FLOOR`]..=[`PACKAGE_ITEM_ID_CEIL`]; for quests, -//! [`PACKAGE_QUEST_ID_FLOOR`]..=[`PACKAGE_QUEST_ID_CEIL`]; for loot rows, -//! [`PACKAGE_LOOT_ID_FLOOR`]..=[`PACKAGE_LOOT_ID_CEIL`]; for `game_creature_spell` rows, -//! [`PACKAGE_CAST_ID_FLOOR`]..=[`PACKAGE_CAST_ID_CEIL`]; for `game_trainer_spell` rows, -//! [`PACKAGE_TRAINER_ID_FLOOR`]..=[`PACKAGE_TRAINER_ID_CEIL`]; for the insertable gossip rows, -//! [`PACKAGE_GOSSIP_ID_FLOOR`]..=[`PACKAGE_GOSSIP_ID_CEIL`]; for the insertable globals rows, -//! [`PACKAGE_GLOBALS_ID_FLOOR`]..=[`PACKAGE_GLOBALS_ID_CEIL`]; for `game_spell_learn` rows, -//! [`PACKAGE_SPELLMETA_ID_FLOOR`]..=[`PACKAGE_SPELLMETA_ID_CEIL`]; for the creature template and -//! spawn rows, [`PACKAGE_CREATURE_ID_FLOOR`]..=[`PACKAGE_CREATURE_ID_CEIL`]; for the gameobject -//! template, trap and spawn rows, -//! [`PACKAGE_GAMEOBJECT_ID_FLOOR`]..=[`PACKAGE_GAMEOBJECT_ID_CEIL`]; for the broadcast text, -//! summon placement and quest event requirement rows, -//! [`PACKAGE_CREATURE_AI_ID_FLOOR`]..=[`PACKAGE_CREATURE_AI_ID_CEIL`]. `game_spell_chain` and -//! `game_spell_proc_event` key on a spell identifier rather than one of their own, so an insert -//! there takes the Package SPELL band. An update may name any row. Tuning real data is the point — -//! except a fixture-reserved one, which no Package may touch under any operation, and except the -//! update-only tables above. See [`ids`] for the bands, the formula a family's band follows, and -//! why they sit where they do. -//! -//! # The other artifact -//! -//! [`script`] holds the **Script Artifact**: the whole Runtime Scripts one Package ships. It is a -//! second kind rather than more tables in the claim schema because a Runtime Script has no base -//! import behind it, so a Package owns the whole row and two Packages meeting on one is a collision -//! rather than a merge. -//! -//! Both kinds live in one Package's generated directory, so a reader that walks `*.json` meets -//! both. [`artifact_kind`] reads the root `kind` member alone and says which is which; each -//! parser refuses the other kind by name rather than complaining about its members. +//! Package Delta format. See `docs/package-delta-format.md` for the contract and rationale. #![forbid(unsafe_code)] #![warn(missing_docs, clippy::pedantic)] diff --git a/crates/lyracore-package-delta/tests/script_artifact.rs b/crates/lyracore-package-delta/tests/script_artifact.rs index d003859dc..bc6e16659 100644 --- a/crates/lyracore-package-delta/tests/script_artifact.rs +++ b/crates/lyracore-package-delta/tests/script_artifact.rs @@ -40,8 +40,6 @@ fn an_artifact_reads_every_member_of_every_script() { assert_eq!(script.source(), "grant_xp(event.actor, 10)"); } -/// A Package that ships nothing is a legal statement, not an empty file to guess about. It is how -/// a Package that used to ship scripts says it no longer does. #[test] fn an_artifact_may_ship_no_scripts_at_all() { let parsed = ScriptArtifact::parse(&artifact("example.quiet", &[])).expect("artifact parses"); diff --git a/crates/lyracore-package-delta/tests/script_refusals.rs b/crates/lyracore-package-delta/tests/script_refusals.rs index 303a405b2..eff6bcaf6 100644 --- a/crates/lyracore-package-delta/tests/script_refusals.rs +++ b/crates/lyracore-package-delta/tests/script_refusals.rs @@ -145,9 +145,6 @@ fn both_ends_of_the_package_band_are_shippable() { } } -/// The band's placement, not just its ends: a fixture-reserved identifier is UNSPELLABLE as a -/// script rather than refused by a second check, because the whole band sits below every reserved -/// one. `ids.rs` asserts that at compile time; this states the consequence a package author sees. #[test] fn no_reserved_identifier_is_inside_the_package_script_band() { for reserved in [ diff --git a/crates/lyracore-shared/src/constants.rs b/crates/lyracore-shared/src/constants.rs index 0d39ed3fd..e5dec2591 100644 --- a/crates/lyracore-shared/src/constants.rs +++ b/crates/lyracore-shared/src/constants.rs @@ -52,9 +52,7 @@ pub mod player_flags { pub const AFK: u32 = 0x0002; /// `PLAYER_FLAGS_DND` (0x04), cm:Player.h:371. pub const DND: u32 = 0x0004; - /// `PLAYER_FLAGS_GHOST` (0x10). The gameplay GHOST state set on Release Spirit: the - /// player is dead-but-walking (can move/run to the corpse, can't act). Distinct from the ghost - /// *render* — that's `unit_vis_flags::GHOST` in `UNIT_FIELD_BYTES_1`. + pub const GHOST: u32 = 0x0010; } @@ -128,7 +126,7 @@ pub mod unit_vis_flags { /// `UNIT_FIELD_BYTES_2` BYTE 0 — the sheath state (vanilla 1.12), stored in /// `game_world_entity.unit_bytes_2`. Says whether a weapon is DRAWN or STOWED; the per-item /// `item_template.sheath` byte (a different field, sent in the item query) says WHERE a stowed -/// weapon hangs. Both are needed to render a sheathed weapon correctly. [#101] +/// weapon hangs. Both are needed to render a sheathed weapon correctly. pub mod sheath_state { /// Nothing drawn — weapons hang in their stow positions. The state every row starts in. pub const UNARMED: u8 = 0; @@ -344,14 +342,14 @@ pub mod taxi_fixture { (0xF130_u64 << 48) | ((FLIGHT_MASTER_ENTRY as u64) << 24) | 1; } -/// Gossip menu OPTION (work-item 217): `game_gossip_option.action` codes as they land verbatim from +/// Gossip menu OPTION : `game_gossip_option.action` codes as they land verbatim from /// the cmangos dump's `gossip_menu_option.OptionType`/`option_id` column (the importer copies it /// through unchanged — this module documents what the values MEAN so the gateway dispatch and the /// importer agree without duplicating magic numbers). `[V]` — confirm against your own dump; only /// GOSSIP/BANKER/VENDOR/TAXI/TRAINER/INNKEEPER are read by the dispatcher today, the rest are /// inert (submenu navigation remains deferred). pub mod gossip_option { - pub const GOSSIP: u32 = 1; // plain text / submenu link (submenu navigation deferred, work-item 217) + pub const GOSSIP: u32 = 1; /// Quests reach the window through its QUEST section, never an option row, so the importer drops /// these — the dump's rows carry the literal placeholder label "GOSSIP_OPTION_QUESTGIVER". pub const QUESTGIVER: u32 = 2; @@ -366,7 +364,7 @@ pub mod gossip_option { /// unlearn my talents." row imports with `action=GOSSIP` (cmangos gates it in C++ code at /// GossipHello, not via this column), so the importer reclassifies that specific row's text to /// this action at import time (`resolve_gossip_option_text`'s caller in `importer/src/main.rs`). - /// Gated to level 10+ (talents don't exist below that) by `filtered_gossip_options` — #516. + /// Gated to level 10+ (talents don't exist below that) by `filtered_gossip_options`. pub const UNLEARNTALENTS: u32 = 16; } @@ -374,7 +372,7 @@ pub mod gossip_option { /// hides the talent pane below this). Gates the "I wish to unlearn my talents." gossip option. pub const MIN_TALENT_LEVEL: u8 = 10; -/// `game_gossip_option.cond_type` — the MINIMAL condition set work-item 217 enforces (quest-status +/// `game_gossip_option.cond_type`, the MINIMAL condition set enforces (quest-status /// gates, the common case in the dump). Anything the importer can't map to one of these folds to /// `NONE` (fail-open + logged), so an unsupported condition never wrongly HIDES an option. The one /// exception is `NEVER`, for a gate whose subject does not exist here at all. @@ -402,7 +400,7 @@ mod tests { /// `packed_with` must touch BYTE 0 only. Byte 1 is PvP flags, byte 2 pet flags, byte 3 the /// shapeshift form — a sheath toggle that cleared a druid's form byte would pop them out of Bear - /// on every `Z` press, which is exactly the class of bug a blind `= state as u32` would ship. [#101] + /// on every `Z` press, which is exactly the class of bug a blind `= state as u32` would ship. #[test] fn packing_a_sheath_state_preserves_the_other_three_bytes() { let neighbours = 0xAB_CD_EF_00_u32; // bytes 1-3 occupied, byte 0 clear @@ -449,7 +447,7 @@ mod tests { assert_eq!(npc_flags::AUCTIONEER, 0x1000); } - /// Work-item 041: pin the shared GameObject QUESTGIVER type id against cmangos — this is the + /// pin the shared GameObject QUESTGIVER type id against cmangos, this is the /// SINGLE source both `module/src/gameobject.rs::go_type::QUESTGIVER` and the gateway's /// `CMSG_GAMEOBJ_USE` dispatch read, so a silent edit here would desync both sides at once. #[test] @@ -463,7 +461,7 @@ mod tests { assert_eq!(go_type::MEETINGSTONE, 23); } - /// Work-item 217: the gossip option action codes the dispatcher matches on must be pairwise + /// the gossip option action codes the dispatcher matches on must be pairwise /// distinct — a collision here would silently misroute one action to another's handler. #[test] fn gossip_option_actions_are_distinct() { diff --git a/crates/lyracore-shared/src/group.rs b/crates/lyracore-shared/src/group.rs index e2a8fc299..ec2ddadb2 100644 --- a/crates/lyracore-shared/src/group.rs +++ b/crates/lyracore-shared/src/group.rs @@ -190,7 +190,7 @@ pub mod event_kind { pub const DECLINE: u8 = 2; /// You are no longer in a group (left / kicked / disbanded) → `SMSG_GROUP_DESTROYED`. pub const DESTROYED: u8 = 3; - // --- work-item 187 (group loot methods) / work-item 221 (money-loot split) — the roll/master- + // --- (group loot methods) / (money-loot split), the roll/master- // loot/money-share relay REUSES this same per-recipient event table + relay (see // `crate::loot_roll`'s module doc for why: identical shape — one recipient, a kind byte, a small // payload — so a parallel new table would only add binding-checklist ceremony for zero @@ -325,8 +325,6 @@ pub mod bot_op { /// Module. Gateway reads at most one head from each lane per dispatch turn. pub const COMMAND_DISPATCH_LANES: u8 = 8; -/// A terminal target receipt and its source response remain recoverable for this long after the -/// command's admission deadline. pub const COMMAND_RESULT_WINDOW_MICROS: i64 = 30_000_000; /// Why the Module refused a party Durable Request. The tag is the whole reducer error text, so @@ -783,7 +781,7 @@ mod tests { } } - /// AC 3, 4: a full destination Subgroup refuses, an out-of-range one refuses, and the + /// 3, 4: a full destination Subgroup refuses, an out-of-range one refuses, and the /// Assistant bit survives an accepted move. #[test] fn a_subgroup_move_refuses_a_full_or_out_of_range_destination() { @@ -813,7 +811,7 @@ mod tests { assert_eq!(mover.moved_to_subgroup(3, 5), Ok(None)); } - /// AC 6: a swap between two full Subgroups needs no capacity Gate: the pair trade places + /// 6: a swap between two full Subgroups needs no capacity Gate: the pair trade places /// atomically, so neither Subgroup ever holds six members mid-swap. #[test] fn a_subgroup_swap_needs_no_capacity_gate() { @@ -829,7 +827,7 @@ mod tests { ); } - /// AC 7: cm:GroupHandler.cpp:939: two members already in one Subgroup swap to a no-op. + /// 7: cm:GroupHandler.cpp:939: two members already in one Subgroup swap to a no-op. #[test] fn a_subgroup_swap_within_one_subgroup_is_a_no_op() { let first = RaidSlot::new(4, true).unwrap(); @@ -918,7 +916,7 @@ mod tests { } } - // ---- Realm-core party ops (issue #22, group slice) ---- + // ---- Realm-core party ops (group slice) ---- /// The intent op byte is a MIGRATION value as well as a wire one: `op` was END-appended to /// `game_bot_invite_intent` with a `0` default, so every row written before the column existed diff --git a/crates/lyracore-shared/src/instance.rs b/crates/lyracore-shared/src/instance.rs index 0dce28695..d1d93de29 100644 --- a/crates/lyracore-shared/src/instance.rs +++ b/crates/lyracore-shared/src/instance.rs @@ -1,27 +1,6 @@ -//! Which maps are DUNGEONS: the instancing fact both tiers have to agree on. The Instance Removal -//! countdown length ([`INSTANCE_REMOVAL_MS`]) is a second, smaller one. -//! -//! The module needs it to decide whether a portal resolves-or-creates a `game_instance` instead of -//! landing at instance 0 (`module::instance::is_dungeon_map`, work-item 190 / issue #39). The -//! gateway needs the SAME set to answer "when this realm creates a dungeon instance, which database -//! will spawn its population, and does that database host populations at all" -//! (`gateway::config::ShardMap::check_instance_hosting`) — the check that turns issue #48's silent -//! empty dungeons into a startup failure. -//! -//! It lives here rather than in either tier because a *disagreement* is the failure mode: a gateway -//! that thought map 36 was open world would never check map 36's instance hosting, and the operator -//! would be back to eight consecutive dungeons with 0 entities and four bot tests blaming gameplay. -//! -//! # Why a const set and not data -//! -//! The honest source is `Map.dbc`'s instanceable flag, which no importer family loads today (nothing -//! else needs `Map.dbc`), so the set is code — one line per dungeon as content lands. Deadmines is -//! the only imported dungeon (work-item 226). -//! -//! Deliberate simplification: a hardcoded slice. Ceiling — a dungeon whose content is imported but -//! whose id is not added here silently behaves like open world (and its hosting is never checked). -//! Upgrade path is the importer loading `Map.dbc` into a table both tiers read; that costs a new -//! static table and a gateway binding to answer a question with 1 row in it today. +//! Shared dungeon-map classification and Instance Removal countdown. +//! The Module uses the map set to create dungeon Instances. The Gateway uses it to verify +//! that each dungeon has a Shard that hosts its population. /// The maps that are DUNGEONS — entering an areatrigger portal targeting one of these resolves-or- /// creates an instance instead of landing at instance 0. @@ -50,14 +29,14 @@ mod tests { assert!(!is_dungeon_map(1), "Kalimdor is open world"); } - /// The set is what the gateway's #48 hosting check iterates. An EMPTY set makes that check + /// The set is what the gateway's hosting check iterates. An EMPTY set makes that check /// vacuous — it would pass for every configuration, including the one that files eight leases /// with 0 entities — so "there is at least one dungeon to check" is itself the invariant. #[test] fn the_dungeon_set_is_never_empty() { assert!( !DUNGEON_MAPS.is_empty(), - "DUNGEON_MAPS is empty — the gateway's instance-hosting check (#48) iterates it, so an \ + "DUNGEON_MAPS is empty — the gateway's instance-hosting check iterates it, so an \ empty set silently turns that check into a no-op" ); assert!( diff --git a/crates/lyracore-shared/src/loot_roll.rs b/crates/lyracore-shared/src/loot_roll.rs index 2eb3ec0f3..d7acc2f47 100644 --- a/crates/lyracore-shared/src/loot_roll.rs +++ b/crates/lyracore-shared/src/loot_roll.rs @@ -1,38 +1,10 @@ -//! The module↔gateway NEED/GREED-ROLL + MASTER-LOOTER wire contract (work-item 187, slices 2-4). -//! -//! **Relay-pattern decision**: rolls/master-loot notifications ride the SAME per-recipient -//! `game_group_event` table + relay the group system already uses for INVITE/LIST/DECLINE/DESTROYED -//! (`crate::group`) — they are NOT a new gateway-subscribed table. The shape every one of these -//! needs is identical: one recipient (RLS-scoped by identity), a small kind byte, and a compact -//! payload the recipient's own connection decodes. Standing up a parallel `game_loot_roll_event` -//! table would require the FULL new-table gateway-binding checklist (binding file pair + ~13 -//! `bindings/mod.rs` splice sites) for zero behavioral gain over reusing the existing relay — the -//! `game_group_event.kind` byte already discriminates payload shape per-row, which is exactly what a -//! second table would also need to do internally. `crate::group::event_kind::LOOT_ROLL_RESERVED_START` -//! documents the kind-byte range reserved here so the two producers (group.rs, loot.rs, both -//! module-side) can never collide on a kind number. -//! -//! The actual per-item roll STATE (`game_loot_roll` / `game_loot_roll_vote`) lives in -//! `module/src/loot.rs`. Client-visible transitions (start / a vote lands / the winner is decided / -//! the master's eligible-recipient list) ride the SAME `game_group_event` relay this doc describes — -//! the gateway never needs a wire binding to `game_loot_roll` for the CLIENT'S sake, and that rule is -//! unchanged by issue #50 below. -//! -//! **Issue #50** moves `game_loot_roll` / `game_loot_roll_vote` to be authoritative on realm-core, -//! alongside `game_group` / `game_group_member` (#22) — a roll's audience is defined by who is in the -//! group, not by where anyone stands, exactly like the membership it depends on. Unlike #22's -//! membership tables, nothing else in the module reads a roll's internal vote state (only the -//! lifecycle functions in `loot.rs` do), so there is no world-shard MIRROR to keep in sync — instead -//! the gateway's coordinator DOES subscribe `game_loot_roll` / `game_loot_roll_vote` now (a first for -//! this pair), purely so its loot-roll relay (`gateway/src/world/loot.rs`) can see a freshly-created -//! roll on a world shard and PROMOTE it onto realm-core (`realm_loot_op`'s `loot_op::START`), then -//! clear the shard's transient staging copy. Voting, once promoted, is routed the same way -//! (`loot_op::VOTE`); the winning GRANT settles back onto the corpse's own world shard through a -//! separate operator reducer (`settle_loot_roll`) the relay calls after observing the `ROLL_WON` -//! event realm-core pushes — items and their escrow guarantees never leave the world shard. +//! Shared loot-roll, master-loot, and money-share wire codes. +//! Client transitions use the per-recipient `game_group_event` relay. +//! Realm-core owns roll votes; World Shards stage new rolls and settle the winning item. +//! See `docs/realm-loot-routing.md` for ordering across databases. /// Kinds carried in `game_group_event.kind` for a loot-roll/master-loot/money-share notification -/// (work-item 187 slices 2-4, work-item 221). Reserved range `4..=8` — see +/// Reserved range `4..=8`, see /// `crate::group::event_kind::LOOT_ROLL_RESERVED_START`. pub mod event_kind { /// A need/greed/pass window opened for one item → `SMSG_LOOT_START_ROLL`. @@ -46,7 +18,7 @@ pub mod event_kind { /// The master looter's eligible-recipient list for a just-appeared above-threshold row → /// `SMSG_LOOT_MASTER_LIST`. Sent to the master only. pub const MASTER_LIST: u8 = 7; - /// Work-item 221: a grouped money-loot split resolved — this recipient's copper share → + /// a grouped money-loot split resolved, this recipient's copper share → /// `SMSG_LOOT_MONEY_NOTIFY(share)`. Next free kind after `MASTER_LIST`; reuses the SAME /// per-recipient relay (one recipient, a kind byte, a small payload) rather than a new gateway- /// subscribed table, for the identical reasons the roll/master-loot kinds above do. Pushed to @@ -54,11 +26,11 @@ pub mod event_kind { /// own "Your share of the loot is X" line too, not the solo "You loot X copper" line) — a SOLO /// money loot never pushes this kind at all (no `game_corpse_loot_eligible` snapshot for that /// corpse), so the gateway sends no notify and the client's own local "You loot" line is the only - /// feedback (work-item 221's whole fix). + /// feedback. pub const MONEY_SHARE: u8 = 8; } -/// The REALM-CORE loot-roll ops (issue #50): the `op` byte of the operator-gated `realm_loot_op` +/// The REALM-CORE loot-roll ops : the `op` byte of the operator-gated `realm_loot_op` /// reducer, mirroring `crate::group::realm_op`'s one-reducer-not-several trade (see that module's /// doc for why: a hand-maintained gateway binding per reducer, `docs/danger-zones.md` §1.2). /// @@ -80,7 +52,7 @@ pub mod loot_op { /// Vote kinds (`game_loot_roll_vote.vote`) — matches the vanilla wire `RollVote` enum EXACTLY /// (`Pass=0, Need=1, Greed=2`; `Disenchant=3` unused here) so a vote byte from `CMSG_LOOT_ROLL` /// (gateway-decoded via `RollVote::as_int()`) round-trips to the module with ZERO translation table — -/// avoiding the #1 silent-bug shape in this codebase: an invented enum ordering that doesn't match +/// avoiding the silent-bug shape in this codebase: an invented enum ordering that doesn't match /// the wire. pub mod vote_kind { pub const PASS: u8 = 0; @@ -161,10 +133,6 @@ pub fn decode_vote(payload: &str) -> Option<(u64, u8, u32, u8, u8, bool, u32)> { )) } -/// Encode a `ROLL_WON` payload: `corpse_guid,slot,item_entry,winning_roll,winning_vote`. The -/// winner's guid rides `GroupEvent.other_guid`, matching `encode_vote`'s convention. -/// `winning_vote` is the tier that won (`vote_kind::NEED` or `GREED`) — without it the wire's -/// won-line misreports every greed-only win as a Need win (187 review finding #3). pub fn encode_won( corpse_guid: u64, slot: u8, @@ -198,7 +166,7 @@ pub fn decode_won(payload: &str) -> Option<(u64, u8, u32, u8, u8, u32)> { )) } -/// Encode a `MASTER_LIST` payload: `corpse_guid|guid,guid,...` (the eligible-recipient set). +/// Encode a `MASTER_LIST` payload: `corpse_guid|guid,guid...` (the eligible-recipient set). pub fn encode_master_list(corpse_guid: u64, eligible: &[u64]) -> String { let guids: Vec = eligible.iter().map(|g| g.to_string()).collect(); format!("{corpse_guid}|{}", guids.join(",")) @@ -354,7 +322,7 @@ mod tests { assert!(decode_money_share("-1").is_none()); // u32, no sign } - // ---- Realm-core loot-roll ops (issue #50) ---- + // ---- Realm-core loot-roll ops ---- /// The op byte is a WIRE value the gateway sends and the module dispatches on, deployed /// separately — a renumber only one side learns about silently runs the wrong op (a VOTE diff --git a/crates/lyracore-shared/src/nav.rs b/crates/lyracore-shared/src/nav.rs index 9563eebfa..eb850fb25 100644 --- a/crates/lyracore-shared/src/nav.rs +++ b/crates/lyracore-shared/src/nav.rs @@ -1,21 +1,3 @@ -//! Nav-grid cell math + blob codec shared by the module's runtime queries (242) and the -//! importer's rasterizer (241) — same one-copy rule as `terrain.rs` (PR-9 review). -//! -//! One nav chunk per terrain MCNK cell (33.33 yd): a 64×64 walkability bitmask (0.52 yd nav -//! cells) and a 32×32 obstruction-height byte grid (1.04 yd) for line-of-sight. Cells with no -//! obstruction/slope/hole anywhere emit NO row — a missing chunk means "unobstructed here (or -//! un-imported)", and both readers fall back to today's straight-line behavior. -//! -//! Blob formats (fixed, versionless — schema changes reload the table): -//! - walk: 512 bytes; bit `ny*64+nx`, 1 = standable. Sub-indices count DOWN from the cell's -//! high-coordinate corner, the same direction as `terrain::cell_index`. -//! - obs: 1024 bytes; `oy*32+ox`; 255 = no obstruction, else top-of-obstruction = -//! `base_z + value * OBS_STEP` (`base_z` rides on the row). Height-above-BASE, not -//! above-terrain, so a LoS ray tests against it with zero interpolation. -// Deliberate simplification: one obstruction column per 1 yd cell — can't express "clear under the -// bridge / two floors". Ground-floor WMO surfaces can replace nearby terrain; stacked floors -// still require navigation layers. - use crate::spatial::MAP_COORD_MAX; use crate::terrain::{interpolate, CELL_SIZE}; use crate::vmap::{TriClass, VmapTri}; @@ -125,7 +107,7 @@ mod tests { } // ============================================================================================= -// Runtime queries (work-item 242) — pure algorithms over fetched chunks. The chunk source is +// Runtime queries, pure algorithms over fetched chunks. The chunk source is // a closure (module: one PK find per cell; tests: synthetic chunks) so ALL pathing/LoS logic // unit-tests here without a database. Missing chunk = "no obstacles known" (the 241 contract: // fully-clear cells emit no row), so every query degrades to today's straight-line behavior @@ -364,7 +346,7 @@ pub fn walkable_prefix( /// including `to`, or None when unreachable within `max_expansions`. The straight-line fast /// path returns `[to]` with ZERO expansions — an open-field chase costs one line test. /// Callers keep legs SHORT (the 500 ms tick's chase legs); long travel stays on the waypoint -/// graph (work-item 150). +/// graph. pub fn find_leg( fetch: &mut impl FnMut(u16, u16) -> Option, from: (f32, f32), diff --git a/crates/lyracore-shared/src/quest.rs b/crates/lyracore-shared/src/quest.rs index edb79f265..69b20fd92 100644 --- a/crates/lyracore-shared/src/quest.rs +++ b/crates/lyracore-shared/src/quest.rs @@ -63,7 +63,7 @@ pub fn quest_xp(reward_money_max_level: u32) -> u32 { (((reward_money_max_level as u64) * 5).div_ceil(3)) as u32 } -/// Work-item 194 (sharing): kinds carried in `game_group_event.kind` for `push_quest_to_party`'s +/// (sharing): kinds carried in `game_group_event.kind` for `push_quest_to_party`'s /// notifications. Reserved slots `10..=11`; kind 9 is retired party chat and never reused. See /// `crate::group::event_kind` for the full reserved-range bookkeeping (loot-roll kinds `4..=8` live /// in the sibling `crate::loot_roll` file). @@ -79,12 +79,6 @@ pub mod share_event_kind { pub const QUEST_PUSH_RESULT: u8 = 11; } -/// Work-item 194 (sharing): the `MSG_QUEST_PUSH_RESULT` wire codes (mirrors gtker's -/// `QuestPartyMessage::as_int()` exactly — `SharingQuest=0, CantTakeQuest=1, TooFar=4, LogFull=6, -/// HaveQuest=7, FinishQuest=8` — so the gateway's decode of a `QUEST_PUSH_RESULT` payload is a plain -/// `u8::try_into()`, never a translation table). Not every gtker variant is reachable from -/// `share_result` (this slice never produces `AcceptQuest`/`DeclineQuest`/`Busy` — there is no -/// asynchronous accept/decline round trip here, only the push + its immediate feedback). pub mod share_result { pub const SHARING_QUEST: u8 = 0; pub const CANT_TAKE_QUEST: u8 = 1; @@ -229,7 +223,7 @@ mod tests { assert!(!class_allowed(u32::MAX, u8::MAX)); } - // ---- Level-cap payout (work-item 194(e)) ---- + // ---- Level-cap payout(e)) ---- #[test] fn max_level_money_reward_uses_the_documented_v_coefficient() { @@ -239,7 +233,7 @@ mod tests { assert_eq!(max_level_money_reward(u32::MAX), u32::MAX); // saturates, never wraps/panics } - // ---- Sharing (work-item 194): share_event_kind distinctness + share_result priority ---- + // ---- Sharing : share_event_kind distinctness + share_result priority ---- /// `QUEST_SHARE`/`QUEST_PUSH_RESULT` (10/11) follow every loot-roll kind (4..=8) and skip kind /// 9, retired party chat, which nothing may reuse. diff --git a/crates/lyracore-shared/src/region.rs b/crates/lyracore-shared/src/region.rs index 8249be2e8..4c59abf2a 100644 --- a/crates/lyracore-shared/src/region.rs +++ b/crates/lyracore-shared/src/region.rs @@ -1,4 +1,4 @@ -//! Regions: the middle rung of the cell / region / shard hierarchy (issue #23, spec #12). +//! Regions: the middle rung of the cell / region / shard hierarchy. //! //! Cells (50yd, [`crate::spatial`]) stay the fixed spatial primitive. A **region** is a contiguous //! set of cells defined in DATA — never computed from gameplay state — and a **shard** owns one or @@ -15,8 +15,7 @@ //! //! # The format is content data //! -//! See `docs/region-sharding.md` for the seam-menu format and a worked example; [`RegionMap::parse`] -//! is its only parser. +//! [`RegionMap::parse`] defines the region format and geometry rules. // Deliberate simplification: a region is an axis-aligned cell RECTANGLE, not an arbitrary // contiguous cell set. A rectangle is trivially contiguous, `region_of` is four integer @@ -29,10 +28,8 @@ use crate::spatial::{grid_cell, GRID_CELL_SIZE}; -/// The region every cell belongs to until a seam menu says otherwise: "the rest of the map". -/// Region 0 is never assignable — a point in it routes through the ordinary `(map, instance)` shard -/// map, i.e. exactly as it did before regions existed. That is what makes an unimported map, or an -/// imported map with a hole in its menu, a strict no-op. +/// Cells outside the authored rectangles use the ordinary `(map, instance)` Shard map. +/// Region zero cannot receive an assignment. pub const DEFAULT_REGION: u32 = 0; /// Minimum side of a region, in cells — the spec's ~10×10 floor. The ~100yd interaction radius is @@ -97,9 +94,7 @@ impl Region { /// The baked cell→region lookup for the whole realm: every map's accepted region definitions. /// -/// **Empty is the identity.** A `RegionMap` with no definitions answers [`DEFAULT_REGION`] for every -/// point, which routes through the ordinary shard map — so an un-imported deployment behaves exactly -/// as it did before this type existed. Every rejection path below degrades toward that identity. +/// With no accepted definitions, every point resolves to the ordinary Shard map. #[derive(Clone, Debug, Default, PartialEq)] pub struct RegionMap { regions: Vec, @@ -147,7 +142,7 @@ impl RegionMap { (Self { regions }, rejected) } - /// Parse the seam-menu content format (`docs/region-sharding.md`). One region per line (or per + /// Parse the seam-menu content format (`RegionMap::parse`). One region per line (or per /// `;`), `#` starts a comment: /// /// ```text @@ -157,13 +152,7 @@ impl RegionMap { /// /// Returns the map plus one rejection line per row that did not make it, from either the syntax /// or the [`RegionMap::build`] rules. - /// **`#` is stripped BEFORE `;` is split on, and the order matters.** The other way round, a - /// semicolon inside a comment ends the comment: `# a; b` splits into `# a` (a comment) and - /// ` b` (which no longer starts with `#`, so it is a malformed ROW). Since - /// `import_map_regions` refuses a menu with ANY rejection, one semicolon in an English prose - /// comment would fail the whole import — which is exactly what the shipped fixture menu's - /// header did before this was fixed (#327). A `;` in row position still separates rows, so - /// nothing about the documented format changes. + /// Strip comments before splitting row separators. A semicolon in prose must not become a row. pub fn parse(text: &str) -> (Self, Vec) { let mut rows = Vec::new(); let mut rejected = Vec::new(); @@ -215,7 +204,7 @@ impl RegionMap { } /// Bucket a set of open-world positions (already resolved to cells) into per-region player - /// counts — the pure half of issue #78's "which region is crowded" gauge, shared by the gateway + /// counts for the region occupancy gauge shared by the Gateway /// (which owns the impure half: reading live positions off its subscription cache) and its /// tests. /// @@ -497,7 +486,7 @@ mod tests { #[test] fn a_semicolon_inside_a_comment_does_not_start_a_new_row() { - // Found shipping #327's fixture menu: `split(['\n', ';'])` split BEFORE the `#` strip, so + // A fixture menu with semicolon-separated rows: `split(['\n', ';'])` split BEFORE the `#` strip, so // the tail of any prose comment containing a semicolon became a malformed row — and // `import_map_regions` errors on ANY rejection, so a single semicolon in the header would // have failed the whole import. Comments run to end of LINE, full stop. diff --git a/crates/lyracore-shared/src/spatial.rs b/crates/lyracore-shared/src/spatial.rs index 2b9673857..e3d259a49 100644 --- a/crates/lyracore-shared/src/spatial.rs +++ b/crates/lyracore-shared/src/spatial.rs @@ -38,7 +38,7 @@ pub fn grid_cell(x: f32, y: f32) -> (i32, i32) { } /// Pack a `(grid_x, grid_y)` cell address into ONE `i64` — the `cell` column every AOI-scoped table -/// carries alongside its `grid_x`/`grid_y` pair (#456). +/// carries alongside its `grid_x`/`grid_y` pair. /// /// # Why a packed column exists at all /// @@ -48,7 +48,7 @@ pub fn grid_cell(x: f32, y: f32) -> (i32, i32) { /// index-served at all — `IndexProbe::Range` is documented in that release as "we currently never /// construct this variant" — and a disjunction (`OR`) is evaluated row-by-row, never turned into /// multiple probes. So the 4-column `by_grid` index `(map_id, instance_id, grid_x, grid_y)` is -/// *unusable* from SQL, and a `grid_x BETWEEN .. AND grid_y BETWEEN ..` box is a residual filter over +/// *unusable* from SQL, and a `grid_x BETWEEN.. AND grid_y BETWEEN..` box is a residual filter over /// a full scan no matter how the columns are ordered. Folding the two grid columns into one turns the /// only shape the planner CAN serve — a 3-column all-equality probe on /// `(map_id, instance_id, cell)` — into an exact match for a single grid cell. @@ -148,7 +148,7 @@ impl GridBox { /// Inclusive grid-cell bounds of the box (anchor ± [`BOX_HALF_SPAN`]): `(gx_min, gx_max, gy_min, /// gy_max)` — the WHERE-clause range the AOI subscription uses (`grid_x BETWEEN gx_min AND gx_max AND - /// grid_y BETWEEN ...`). The SAME range drives the module's movement-recipient selection, so the + /// grid_y BETWEEN...`). The SAME range drives the module's movement-recipient selection, so the /// gateway's spawn set and the peer-movement relay stay aligned. pub fn bounds(&self) -> (i32, i32, i32, i32) { ( @@ -159,19 +159,12 @@ impl GridBox { ) } - /// Every grid cell in the box as `(gx, gy)` — the ENUMERATION form of [`GridBox::bounds`], for a - /// caller that probes a `(map_id, instance_id, grid_x, grid_y)` btree cell by cell instead of - /// filtering a full scan with the four `bounds()` inequalities (the module's movement-recipient - /// selection does exactly that). Kept here, next to `bounds()`, because the two MUST agree: the - /// unit test asserts the emitted set is exactly the cells `bounds()` accepts, with no duplicate — - /// a range typo would otherwise silently shrink or double a peer's movement-recipient set, which - /// is invisible in code review and shows up live as a frozen or double-stepping peer. pub fn cells(self) -> impl Iterator { let (gx_min, gx_max, gy_min, gy_max) = self.bounds(); (gx_min..=gx_max).flat_map(move |gx| (gy_min..=gy_max).map(move |gy| (gx, gy))) } - /// The box's cells as PACKED ids (#456) — [`GridBox::cells`] run through [`grid_cell_id`]. This is + /// The box's cells as PACKED ids, [`GridBox::cells`] run through [`grid_cell_id`]. This is /// what the AOI subscription enumerates: one equality query per cell is the only box-shaped read /// SpacetimeDB 2.7.1's planner can serve entirely from an index (see [`grid_cell_id`]). pub fn cell_ids(self) -> impl Iterator { @@ -192,7 +185,7 @@ impl GridBox { } /// An axis-aligned grid-cell RECTANGLE with no anchor — the per-shard split unit for cross-seam -/// view-merge (issue #73). A [`GridBox`] is always a fixed-size square around one anchor cell; a +/// view-merge. A [`GridBox`] is always a fixed-size square around one anchor cell; a /// `GridRect` is any inclusive `(gx_min..=gx_max) × (gy_min..=gy_max)` span, which is what a straddling /// box's per-shard share looks like after [`crate::region`]'s rectangle boundaries cut it. /// @@ -229,7 +222,7 @@ impl GridRect { (self.gx_min, self.gx_max, self.gy_min, self.gy_max) } - /// Every cell in the rect as a PACKED id (#456) — the rect twin of [`GridBox::cell_ids`], and what + /// Every cell in the rect as a PACKED id, the rect twin of [`GridBox::cell_ids`], and what /// a per-shard AOI rect actually subscribes. A rect built from a whole box /// ([`GridRect::from_box`]) emits the identical id sequence [`GridBox::cell_ids`] does, which is /// what keeps a non-straddling recenter byte-identical to the login subscription. @@ -258,7 +251,7 @@ mod tests { assert!(b.needs_recenter(0, -8949.95 - 600.0, -132.493)); } - /// #73: a non-straddling recenter's home share must be BYTE-IDENTICAL to today's whole-box + /// a non-straddling recenter's home share must be BYTE-IDENTICAL to today's whole-box /// query, and this is the property that makes that true — `GridRect::from_box` emits the exact /// same 4-tuple `GridBox::bounds` does, so a query builder driven by either type produces the /// same SQL string for the same box. @@ -313,7 +306,7 @@ mod tests { ); } - /// #456: the packed `cell` id must be a BIJECTION over `(i32, i32)`. Every writer of `grid_x`/ + /// the packed `cell` id must be a BIJECTION over `(i32, i32)`. Every writer of `grid_x`/ /// `grid_y` also writes this value and the AOI subscription probes it with an equality, so a /// collision would silently merge two cells — two players 500yd apart would see each other, and /// the entities of one cell would be delivered to the wrong box. Round-tripping is the proof: diff --git a/crates/lyracore-shared/src/terrain.rs b/crates/lyracore-shared/src/terrain.rs index 1d3413fc7..89e7f7fc9 100644 --- a/crates/lyracore-shared/src/terrain.rs +++ b/crates/lyracore-shared/src/terrain.rs @@ -1,11 +1,3 @@ -//! Terrain cell math shared by the module's `ground_z` and the importer's self-check -//! (work-items 172/173; extracted per PR-9 review — one copy of the interpolation, unit-tested -//! here, with both consumers as thin callers). -//! -//! Geometry (mangos GridDefines parity): 64×64 tiles of 533.33333 yd per map, 16 MCNK cells per -//! tile → 1024×1024 cells of 33.33 yd. Cell indices count DOWN from +`spatial::MAP_COORD_MAX`, -//! the same coords-measured-downward convention as the AOI grid — one origin for both systems. - use crate::spatial::MAP_COORD_MAX; /// Side of one MCNK terrain cell in yards (533.33333 yd tile / 16). diff --git a/crates/lyracore-shared/src/trade.rs b/crates/lyracore-shared/src/trade.rs index 240ad67cb..003420647 100644 --- a/crates/lyracore-shared/src/trade.rs +++ b/crates/lyracore-shared/src/trade.rs @@ -1,4 +1,4 @@ -//! The module↔gateway TRADE wire contract (#120) — event-kind codes for the Trade Session status +//! The module↔gateway TRADE wire contract, event-kind codes for the Trade Session status //! relay. Cross-boundary constants live HERE, both crates import: a module-side renumber becomes a //! compile-visible edit on the gateway side instead of a runtime drift. Same precedent as //! `group::event_kind`. @@ -31,30 +31,30 @@ pub mod event_kind { /// Initiator refusal: the target is dead → `TradeStatus::TargetDead`. pub const TARGET_DEAD: u8 = 8; /// Your OWN offer, echoed back after a mutation (payload = [`super::encode_offer`]) → - /// `SMSG_TRADE_STATUS_EXTENDED` with `self_player = true` (#121). + /// `SMSG_TRADE_STATUS_EXTENDED` with `self_player = true`. pub const OFFER_SELF: u8 = 9; /// Your PARTNER's offer after their mutation (same payload grammar) → - /// `SMSG_TRADE_STATUS_EXTENDED` with `self_player = false` (#121). + /// `SMSG_TRADE_STATUS_EXTENDED` with `self_player = false`. pub const OFFER_PARTNER: u8 = 10; /// Initiator notice: the target has you ignored — proposal declined → - /// `TradeStatus::IgnoreYou` (#123). + /// `TradeStatus::IgnoreYou`. pub const IGNORE_YOU: u8 = 11; - /// Your partner accepted the current offer (#122) → `TradeStatus::TradeAccept`. + /// Your partner accepted the current offer → `TradeStatus::TradeAccept`. pub const TRADE_ACCEPT: u8 = 12; /// An accept was withdrawn — explicit unaccept, or the accept-reset rule firing on an offer - /// change (#122) → `TradeStatus::BackToTrade`. + /// change → `TradeStatus::BackToTrade`. pub const BACK_TO_TRADE: u8 = 13; - /// The Trade Commit succeeded (#122) → `TradeStatus::TradeComplete`. + /// The Trade Commit succeeded → `TradeStatus::TradeComplete`. pub const TRADE_COMPLETE: u8 = 14; /// Commit refused, window closes: YOUR bags cannot fit the incoming items → - /// `TradeStatus::CloseWindow` with `InventoryResult::InventoryFull`, `target_error: false` (#122). + /// `TradeStatus::CloseWindow` with `InventoryResult::InventoryFull`, `target_error: false`. pub const INV_FULL_SELF: u8 = 15; /// Commit refused, window closes: the PARTNER's bags cannot fit → same `CloseWindow`, - /// `target_error: true` (#122). + /// `target_error: true`. pub const INV_FULL_PARTNER: u8 = 16; /// Commit refused, window closes: YOUR purse cannot honour the offer (short, or receipt would /// pass the u32 cap) → `CloseWindow` with `InventoryResult::NotEnoughMoney`, - /// `target_error: false` (#122). + /// `target_error: false`. pub const GOLD_FAIL_SELF: u8 = 17; /// Commit refused, window closes: the PARTNER's purse failed → same, `target_error: true`. pub const GOLD_FAIL_PARTNER: u8 = 18; @@ -77,7 +77,7 @@ pub struct OfferSlot { } /// Encode one side's whole offer for an `OFFER_*` event payload: -/// `gold|slot,entry,display,stack,enchant,dur,maxdur;slot,...` — occupied slots only, `gold` in +/// `gold|slot,entry,display,stack,enchant,dur,maxdur;slot...`, occupied slots only, `gold` in /// copper. All-numeric fields, so no delimiter stripping is needed (unlike /// `group::RosterPayload::encode`). pub fn encode_offer(gold: u32, slots: &[OfferSlot]) -> String { @@ -129,7 +129,7 @@ pub fn decode_offer(payload: &str) -> Option<(u32, Vec)> { mod tests { use super::*; - /// The offer payload round-trips with every window-visible field intact (the #121 "real stack + /// The offer payload round-trips with every window-visible field intact (the "real stack /// count, durability, enchant" AC rides these fields), an empty offer is legal (gold-only or /// cleared window), and malformed payloads fail closed as a whole. #[test] diff --git a/crates/lyracore-shared/src/values_mask.rs b/crates/lyracore-shared/src/values_mask.rs index f568f68a4..afdae270f 100644 --- a/crates/lyracore-shared/src/values_mask.rs +++ b/crates/lyracore-shared/src/values_mask.rs @@ -1,11 +1,5 @@ //! Raw decoder for `SMSG_UPDATE_OBJECT` VALUES blocks (testing-hardening §3.1). -//! -//! gtker's typed reader REJECTS a TYPE-less partial VALUES mask (bit 2 absent — which is exactly -//! what a correct 5875 server sends; see `codec::values`'s dirty_reset doc), so every wire assert -//! on a live field change used to be a bespoke `body.windows(4)` byte scan. This is the one shared -//! decoder instead: parse the leading VALUES objects out of a raw frame and hand back -//! `(field_index, raw_word)` pairs addressed exactly like the update-mask descriptor table -//! (e.g. `PLAYER_SKILL_INFO` starts at field 718, 3 words per slot; `UNIT_FIELD_HEALTH` = 22). + //! //! Scope: VALUES blocks only, and only the LEADING run of them — a CREATE/MOVEMENT block has a //! variable-length movement payload this deliberately does not decode (the typed reader handles diff --git a/crates/lyracore-shared/src/vmap.rs b/crates/lyracore-shared/src/vmap.rs index 958c182c7..63a7a750f 100644 --- a/crates/lyracore-shared/src/vmap.rs +++ b/crates/lyracore-shared/src/vmap.rs @@ -1,21 +1,7 @@ -//! Per-cell collision-triangle blob codec — the exact-model half of decision #10 (`docs/decisions.md` -//! §10, epic #169). One blob per terrain cell (same 33.3 yd `terrain::cell_key` the nav grid bins -//! by): every world-space collision triangle whose AABB touches that cell, carrying enough source -//! metadata for the two vanilla ray flavors the next slice adds (LoS = WMO only, collision = WMO + -//! M2 doodads) — see decision #10 for why LoS excludes doodads. -//! -//! Unlike `nav`'s fixed-size blobs this format is variable-length (a cell's triangle count isn't -//! bounded), so it carries an explicit VERSION byte: a schema change bumps `VMAP_VERSION` and -//! `decode` rejects anything else outright rather than silently misreading bytes. -//! -//! #520 extracted, binned, packed and reported. #521 (this file's `cast_ray` half) adds the query -//! surface: exact segment–triangle tests, walking only the terrain cells the ray's XY projection -//! crosses (a 2D DDA, Amanatides–Woo) rather than sampling every few tenths of a yard like -//! `nav::has_los`'s parametric scan — an exact per-triangle store can afford (and needs) an exact -//! walk. Two vanilla ray flavors: LoS (`RayFlavor::Los`, WMO-class triangles only — doodads/forests -//! never block sight) and collision (`RayFlavor::Collision`, WMO + M2 doodads, for movement/reach -//! clamps). The module-side table + import reducers + `vmap_enabled` gate live in -//! `module/src/vmap.rs`. +//! Per-cell collision triangles and exact segment-triangle ray queries. +//! See `docs/decisions.md` section 10 for why line of sight uses WMO triangles while collision +//! also uses M2 doodads. A versioned blob carries source metadata for both ray flavors. +//! Queries walk only the terrain cells the segment crosses with a two-dimensional DDA. /// Blob format version. Bump on any layout change; `decode` rejects a mismatch rather than guess. pub const VMAP_VERSION: u8 = 1; @@ -37,7 +23,7 @@ pub struct VmapTri { } /// Bytes per packed triangle: 1 (class tag) + 4 (group_id) + 4 (mogp_flags) + 36 (9 × f32 verts). -/// `pub` so the importer can size a per-blob shard cap (`#521`: a dense cell's triangle count +/// `pub` so the importer can size a per-blob shard cap. A dense cell's triangle count /// isn't bounded, but a single `spacetime call` argument is — see `importer/src/vmap.rs`). pub const TRI_BYTES: usize = 1 + 4 + 4 + 36; /// Header: 1 version byte + 4-byte LE triangle count. `pub` for the same reason as `TRI_BYTES`. @@ -126,7 +112,7 @@ pub fn decode(blob: &[u8]) -> Result, DecodeError> { } // =========================================================================================== -// Ray queries (#521) — exact segment–triangle tests over a 2D DDA cell walk. +// Ray queries, exact segment–triangle tests over a 2D DDA cell walk. // =========================================================================================== /// Which vanilla ray flavor to cast: LoS ignores doodads (M2 never blocks sight); collision @@ -544,7 +530,7 @@ mod tests { } // ------------------------------------------------------------------------------------- - // Ray-query tests (#521) — a synthetic stand-in for the done-when's live scenarios: a + // Ray-query tests, a synthetic stand-in for the done-when's live scenarios: a // vertical "column" wall (abbey pillar analogue), a clear segment, and a doodad that // blocks the collision ray but not the LoS ray. // ------------------------------------------------------------------------------------- @@ -693,7 +679,7 @@ mod tests { } // ------------------------------------------------------------------------------------- - // Area-info tests (#527) — a synthetic stand-in for the done-when's live scenarios: a + // Area-info tests, a synthetic stand-in for the done-when's live scenarios: a // horizontal floor quad tagged indoor (abbey-interior analogue) under a probe reports the // group + indoor=true; a probe with no WMO geometry in range reports "outdoors" (`None`). // ------------------------------------------------------------------------------------- diff --git a/docs/accept-policy.md b/docs/accept-policy.md new file mode 100644 index 000000000..5034b52e6 --- /dev/null +++ b/docs/accept-policy.md @@ -0,0 +1,25 @@ +# Accept-loop resource policy + +`gateway/src/accept.rs` classifies listener failures and bounds concurrent blocking work. +An error from one accepted connection must leave the listener available for later connections. + +`classify_accept_error` treats four raw errnos as fatal: + +| errno | listener failure | +| --- | --- | +| `EBADF` | the descriptor is closed | +| `ENOTSOCK` | the descriptor is not a socket | +| `EINVAL` | the socket is not listening or its arguments are invalid | +| `EFAULT` | the address argument is not writable | + +These conditions make retrying the same listener pointless. All other errors are transient, +including unknown errnos and errors without a raw errno. The caller logs each transient error. +`AcceptBackoff` caps repeated failures at one attempt per second. + +Linux includes `EOPNOTSUPP` among pending network errors that `accept` should retry. Its other meaning, +a non-stream socket, cannot apply to a `TcpListener` created by `bind`. `EPERM` can report a firewall +refusal for one connection and also retries. `EMFILE` reports descriptor exhaustion and must leave +existing sessions alive while capacity recovers. + +A shared non-waiting capacity limit keeps accepted sockets out of Tokio's unbounded blocking-task +queue. Holding the permit for the connection's work makes the limit cover the resource it protects. diff --git a/docs/actor-verbs.md b/docs/actor-verbs.md new file mode 100644 index 000000000..4063d4fae --- /dev/null +++ b/docs/actor-verbs.md @@ -0,0 +1,57 @@ +# Actor verbs + +Actor verbs expose the same action operations to player reducers, debug reducers, and Package bots. +They accept an explicit Actor guid and apply the operation's gameplay Gates. + +Existing verbs retain their `Result<(), String>` contract. Typed requests expose accepted +work and pending cast identity without changing the client operations. All Gates remain +in the operation that owns them. Callers authorize the Actor before entering these functions. + +| verb | core | Gates and outcomes | +|------|------|------------------------------------------------------------| +| `attack` | `combat::apply_start_attack` | CC-blocked rejected; no self/corpse/cross-map; friendly (green) target rejected when faction data exists; re-arm retargets | +| `request_attack` | `combat::request_attack` | typed acceptance or Refusal; keeps a matching swing timer; an in-range Character turns toward the exact target when the facing Gate would block its swing unless an active movement leg owns facing; acceptance does not imply damage | +| `ranged_attack` | `combat::apply_start_ranged_attack` | `attack` gates + ranged weapon equipped (slot 17) | +| `stop_attack` | `combat::stop_attack_for` | unconditional disarm of the actor's outgoing melee row | +| `cast_at` | `spell::request_cast` | normal cast lifecycle; an existing timed cast waits; level comes from the live entity | +| `cast_readiness` | `spell::cast_readiness` | read-only spellbook, supported-lifecycle, and cast Gate check | +| `request_cast` | `spell::request_cast` | typed start, waiting, and Refusal; completion uses `on_cast_finished` | +| `accept_quest` | `quest::apply_accept_quest` | alive + giver in range offering the quest + level/race/class/prereq/duplicate gates | +| `stage_quest` | `quest::grant_quest_unchecked` | Debug and bot staging: same row shape, all accept gates SKIPPED (giver-less) | +| `turn_in_quest` | `quest::apply_turn_in_quest` | alive + giver in range ending the quest + objectives complete; rewards atomic | +| `open_creature_loot` | `loot::open_creature_corpse` | legacy result adapter used by the Gateway | +| `request_open_creature_loot` | `loot::request_open_creature_corpse` | typed open acceptance or Refusal; applies the same corpse and Loot Tag Gates | +| `take_loot` | `items::apply_take_loot` | legacy result adapter used by the existing rest-and-loot goal | +| `request_take_loot` | `items::request_take_loot` | typed take completion or Refusal; inventory-full leaves the item and Loot Source unchanged | +| `loot_money` | `loot::apply_loot_money` | alive + dead creature corpse, same map, 10yd, money > 0 + Loot Tag eligibility | +| `buy_item` | `items::apply_buy_item` | vendor in range + stocked + money; stacks/slots validated | +| `sell_item` | `items::apply_item_sell` | vendor in range + sellable item in slot; feeds the buyback ring | +| `use_item` | `items::apply_item_use` | alive + usable item in slot (consumable/on-use gates) | +| `cast_item_target` | `creatures::apply_item_target_spell` | spell kind + owned carried item + effect-specific gates | +| `equip_item` | `items::apply_equip_item` | slot type/level gates | +| `trainer_buy` | `trainer::apply_trainer_buy` | trainer resolved by guid + offering gates (class/level/money) | +| `reconcile_profile_spell` | `trainer::reconcile_profile_spell` | free profile grant + trainer/class/level/rank gates; explicit no-import demo fallback | +| `reconcile_profile_skill` | `skill::reconcile_profile_skill` | free profile grant + race/class/level availability gates | +| `select_profile_talent` | `talent::select_profile_talent` | bounded preferred-tree selection through the owning talent Gates | +| `learn_profile_talent` | `talent::reconcile_profile_talent` | class/race/level/point/tier/prerequisite gates | +| `reconcile_profile_item` | `items::request_profile_item` | bounded top-up + capacity/uniqueness gates | +| `equip_profile_upgrade` | `items::apply_equip_profile_upgrade` | normal equip gates + preserves equal or stronger gear | +| `reconcile_starter_role_spell_levels` | `seed::reconcile_curated_starter_role_levels` | repairs only exact old curated level-zero headers; preserves imported or tuned rows | +| `use_gameobject` | `gameobject::apply_use_gameobject` | GO resolved by guid + range/use gates | +| `request_use_gameobject` | `gameobject::request_use_gameobject` | typed target, partition, range, and use Refusal | +| `area_trigger_route` | `quest::area_trigger_route` | exact imported source volume and target map; landing remains private | +| `enter_sessionless_areatrigger` | `quest::enter_sessionless_areatrigger` | imported source volume + expected certified party partition + session-less action Gates | +| `import_revision` | `game_import_meta` read | current importer source and file identities for one family | +| `repop` | `world::do_repop` | dead actor releases to the graveyard ghost | +| `respond_resurrect` | `spell::do_resurrect_response` | consume the actor's pending rez offer; accept revives IN PLACE at the offer's % | +| `spirit_res` | `world::do_spirit_healer_res` | ghost actor res at the spirit healer (sickness applies) | +| `self_resurrect` | `spell::do_self_resurrect` | dead actor with a Self-Resurrection Option revives in place; no sickness | +| `accept_group_invite` | `group::accept_invite_for` | pending invite exists + its Group still exists (or a solo inviter is still ungrouped) + group not full; roster events fire | +| `set_sessionless_action_consent` | `sessionless::set_sessionless_action_consent` | update Package consent and clear unclaimed Group Intents atomically | +| `companion_target_facts` | `group::companion_target_facts` | exact hostile creature + partition/death/control gates; never selects a substitute | +| `system_message` | `chat::emit_system_message` | recipient exists and is online on this Shard; text is trimmed, bounded, and non-empty | + +Add a consumed verb to this table and the re-exports in `module/src/actor.rs`. If its operation +is inlined in a sender-bound reducer, extract the operation and let both callers use it. Delete +unused verbs. `debug_only!` and `package_only!` allow unused imports only when their consumer tree +is absent from the build. diff --git a/docs/character-transfer.md b/docs/character-transfer.md new file mode 100644 index 000000000..082b24f9a --- /dev/null +++ b/docs/character-transfer.md @@ -0,0 +1,51 @@ +# Character Transfer + +`module/src/transfer/mod.rs` owns the Escrow ledger, planners, Store traits, and reducer shims. +`transport.rs` owns the row manifest and BSATN encoding. `tests.rs` checks planner outcomes and +manifest coverage. `harness.rs` executes the protocol against two Fakes. + +A Transfer freezes the source Character before importing it at the destination. The source durable +copy survives until the destination copy is committed. Import is idempotent by Transfer id. +`finish_transfer` refuses without an import receipt, so a retry cannot delete the only durable copy. + +```text +Source Destination +begin_transfer + export blob -------------> import_character_blob + committed <--------------- import receipt +confirm_import +finish_transfer + source gone -------------> release_transfer +``` + +`confirm_import` records the destination's commitment on the source. `release_transfer` clears the +destination fence only after the source copy is gone. A same-database Transfer uses the same ledger +and planners, but changes the Character's partition without making a second durable copy. + +An out-row or in-row makes the Character in transit. Four boundaries enforce the fence: + +- `helpers::entity_by_owner` refuses player actions. +- `world::player_login` refuses to materialize another live entity. +- `begin_transfer` deletes the source live entity, which removes it from target lookup and relays. +- `helpers::character_by_guid` and `character_by_name` hide the durable Character from by-guid callers. + +A by-guid operation needs a deliberate outcome. Most operations refuse through the fenced lookup. +`loot::credit_purse` defers a third party's copper into the Transfer blob. `auth::establish_session` +regenerates connection identity at the destination. Group membership is authoritative on Realm-core; +the Gateway projects it at world entry, and it is excluded from the blob. The Architecture Test in +`module/src/tripwires.rs` enforces the documented raw-lookup exclusions. + +Every transported table has a `character_owned!(transfer, ..)` marker beside its declaration. +Generated registries name each arm and whether it transports rows. The manifest tests reject a +missing arm. `ExportBlob` carries the Character row and one opaque `TableRows` payload per table. +Before applying rows, import refuses unknown tables, malformed payloads, and missing registry +entries. Deliberately untransported tables still require an empty payload entry. + +Recovery never guesses whether a destination import committed. A same-database reaper reads the +local receipt and may roll back an unimported stale Escrow. Across databases, a missing local +receipt means the destination is unconsulted. The reaper holds the Escrow, and the Gateway resumes +forward recovery at the next world entry. A frozen Character is recoverable; deleting or duplicating +one is not. + +The Fakes execute the shared planners and protocol bodies. Durable tests exercise reducer shims, +real table arms, and SpacetimeDB transaction rollback. These checks cover different boundaries. diff --git a/docs/instance-lifecycle.md b/docs/instance-lifecycle.md new file mode 100644 index 000000000..f2a3b6fe3 --- /dev/null +++ b/docs/instance-lifecycle.md @@ -0,0 +1,35 @@ +# Instance lifecycle + +`module/src/instance.rs` owns dungeon leases, Character bindings, population, and removal timers. +Instance zero is the open world. SpacetimeDB allocates `game_instance.id` from one. + +`resolve_or_create_instance` handles dungeon Area Trigger entry. It resolves the Group's live +Instance first, then the Character's live binding, then creates an Instance. A solo Instance can be +adopted by a Group its holder joins, so the other members enter the same run. Character bindings +survive Group disband and expire when the Instance is reaped. + +`create_instance` builds creature entities from the map's authored spawn rows and makes copies of +interactive GameObjects. Templates and authored spawn rows are shared. With `hosts_instances` off, +it creates only a lease; the Shard that owns the map builds population through `ensure_instance`. +The global creature tick covers every Instance without a dedicated tick row. Operators may arm one +for a different cadence. + +`reap_instances` observes player occupancy once per minute. It stamps an empty Instance and reaps it +after 30 minutes empty, or on the next empty observation when `reset_requested` is set. Re-entry +clears the stamp. Teardown removes population, encounter state, the dedicated tick row, Character +bindings, and finally the lease. + +Instance creature populations have no authored spawn rows of their own. Trash does not respawn, +corpses remain until reap, and waypoint, wander, and return-home movement is unavailable. Aggro, +assist, chase, flee, and casting use entity and template state and remain active. + +Creature copies use `encounter::wave_guid` with bit 23 set in the low field. This separates their +identities from imported spawns and encounter wave allocation. GameObject copies use +`0xF110 | bit46 | seq`, below the pool band and above static and debug identifiers. + +A Character standing in a Group's dungeon Instance without membership gets a 60-second removal +timer. `reconcile_instance_removal` runs when membership or location changes, including login and +Group projection. `expire_instance_removal` checks the rule again before moving the Character home. + +All tables are private. The Coordinator subscribes to leases for Transfer admission and removal +rows for countdown messages. Character bindings have no subscription. diff --git a/docs/module-build.md b/docs/module-build.md new file mode 100644 index 000000000..a5422357f --- /dev/null +++ b/docs/module-build.md @@ -0,0 +1,45 @@ +# Module build + +`module/build.rs` discovers Package modules and generates registration arrays in `$OUT_DIR`. +It scans Rust files in `module/src/` and each installed `packages//src/` directory. +`packages/example/` is the maintained reference Package and does not enable `has_packages`. + +A Package supplies `src/mod.rs` and any sibling modules. Discovery generates `package_mods.rs` +with a `pkg_` module for each Package, so installing one requires no Core source edit. + +`character_owned!` markers generate `character_sweeps.rs`: + +- `CHARACTER_OWNED_DELETE_SWEEPS` and `CHARACTER_OWNED_RESTAMP_SWEEPS` contain sweep functions. +- `CHARACTER_OWNED_TABLES` names the tables in the Character transport manifest. +- `CHARACTER_OWNED_TRANSFERS` pairs each table name with its row mover. +- `CHARACTER_OWNED_TRANSFER_NAMES` exposes those names without function pointers for native tests. +- `CHARACTER_OWNED_NOT_TRANSPORTED` names the deliberate exclusions whose reasons the tests check. + +A delete marker must use `sweep_delete_`; a transport marker must use +`sweep_transfer_`. Prefix stripping derives the table names, keeping the +manifest and mover registry tied to the declarations. A `not_transported` marker still +registers a transport arm, which exports an empty entry and absorbs arriving rows. + +`game_tick_pass!`, `game_hook!`, `game_client_command!`, and `encounter_package!` generate +`package_registries.rs`. The registries contain periodic passes, notify hooks, client command +handlers, and map-specific Encounter Authority. Registration wrappers stop Package work after +Package Teardown. `GAME_PACKAGES` lists compiled Packages, their tables, and their Character reads. + +`HOOK_EVENTS` generates `hook_dispatch.rs`, including the payload aliases and a `fire_*` function +for each event. `module/src/hooks.rs` includes it, so callers use the same dispatch paths. + +The marker scanner ignores comments and string literals. It reads invocation heads, without +interpreting function bodies. A live marker must match the supported grammar; malformed markers +fail the build instead of disappearing from the registry. + +Core file paths resolve through their top-level module. Nested marker files must have a visible +facade re-export, which the scanner checks. Package paths resolve through their generated +`pkg_` module. A debug-only source file must start with +`#![cfg(feature = "debug_reducers")]`; discovery observes that gate so an ordinary build cannot +reference a function omitted by Rust. + +Package source also passes the Package API lint described in `docs/package-api.md`. A path outside +`PACKAGE_API_ROOTS` fails with the Package, file, line, and path. An explicit +`// package-api: exempt ` permits an ordinary boundary exception. A gated API root still +requires its debug or test gate, and exemptions cannot bypass that requirement. Core source does +not pass through this lint. diff --git a/docs/package-delta-format.md b/docs/package-delta-format.md new file mode 100644 index 000000000..3a147df3d --- /dev/null +++ b/docs/package-delta-format.md @@ -0,0 +1,128 @@ +# Package Delta format + +The **Package Delta** artifact and the claim conflict tracer. + +A Package Delta is the durable record of what one Package changes in the spell tables. It names +the Package, the source it was generated from, and one claim per row: the table, the typed +primary key, the operation, and the columns the Package sets there. A base import replaces whole +data families, so a Package's row edits have to be a replayable artifact rather than a one-shot +edit that the next import silently reverts. + +This library is pure and deterministic. It reads no files, keeps no state, reads no clock, and +writes nothing. It parses, canonicalizes, and traces; applying a delta to a shard is a separate +concern. The crate also ships `lyracore-delta-check`, a command that reads named artifact files +and prints the plan they form. File access lives in the command. + +# The artifact + +```json +{ + "version": 1, + "package": "example.sharper-fireballs", + "source_hash": "0000000000000000000000000000000000000000000000000000000000000000", + "claims": [ + { + "table": "game_spell", + "key": { "spell_id": 133 }, + "operation": "update", + "fields": { + "cooldown_ms": { "type": "u32", "value": 1500 }, + "family_flags": { "type": "u64", "value": "18446744073709551615" } + } + }, + { + "table": "game_spell_effect", + "key": { "spell_id": 133, "effect_index": 0 }, + "operation": "update", + "fields": { "base_points": { "type": "i32", "value": 120 } } + } + ] +} +``` + +Every value states its type. An unsigned 64-bit value is a decimal **string**, because a JSON +number loses precision above 2^53 and would come back silently wrong. A `game_spell_effect` key +names the spell and the effect index; the packed durable key +(`(spell_id << 2) | effect_index`) is derived, never authored. + +# What it refuses + +Unknown versions, tables, columns, type tags and malformed keys fail at `PackageDelta::parse`, +before an applier sees the artifact. So does row deletion: uninstalling a Package replays the +affected families without its claims, and there is no pre-image a delete could be undone from. +An `update` names only the columns it changes; an `insert` carries the whole row. + +# What it traces + +`trace` merges the claims of several Packages. Different columns of one row merge. The same +column, or two inserts at one primary key, is a `ClaimConflict` naming both Packages and the +exact claim. There are no priority numbers: the tracer reports that a human has to choose. + +# Import Families + +Every claimable table belongs to one Import Family, the unit a base import clears and reloads +and the unit an apply runs for. `Table` is one closed enum with its variants grouped by +family, and `Table::family` names the owner, so an applier called for one family can tell that +a claim belongs to the import it is running. This build's catalogue is the spell, item, quest, +loot, cast, trainer, gossip, globals, spellmeta, creatures, gameobjects and creature-ai +families. `game_creature_quest`/`game_gameobject_quest` (quest givers) and +`game_creature_loot`/`game_npc_vendor` (creature-scoped loot and vendor stock) are out of it. +So is `game_start_item`, which two importer passes own between them, `game_creature_waypoint`, +which carries no map to route by, and the two gameobject pool tables, which no base import +writes. So are the `EventAI` definition tables, whose rules are a nested payload no scalar column +shape can state: a Package Delta claims typed rows, never a script blob. See +`Table::columns`, `LOOT_FAMILY`, `GLOBALS_FAMILY`, `CREATURE_FAMILY`, +`GAMEOBJECT_FAMILY` and `CREATURE_AI_FAMILY` for why. + +# Spatial claims and routing + +Two tables are SPATIAL: `game_creature_spawn` and `game_gameobject`. Their claims carry the map +in the primary key rather than in a column, and `PrimaryKey::map_id` reads it. A spatial claim +reaches only the Shards whose World Import Scope owns that map; the importer applies that filter +with the scope it already builds for the base import, so there is one routing concept and this +crate only has to state which map a row sits on. Every other table is a global catalogue every +Shard loads whole. + +A spawn's durable guid is derived from the key, never authored: `game_creature_spawn` packs its +creature template and spawn identifier into one guid, `game_gameobject` its spawn identifier +alone. The map is not part of either guid, which is why it is identity for routing and not a +claimable column: a Package cannot move a placed row onto a map another Shard owns. + +Most tables permit insert and partial update. Six are update-only, because their primary key +names something no Package may invent: a creature template (`game_creature_cast`, +`game_gossip_menu`), a race, class or level the client fixes (`game_class_level_stats`, +`game_level_stats`, `game_start_position`), or an `AreaTrigger.dbc` trigger id +(`game_areatrigger_teleport`). `DeltaError::InsertNotSupported` refuses every insert on those +at parse time, regardless of identifier. + +# Identifier policy + +An inserted row must sit in its family's Package identifier band, which no client and no import +can reach: for spells, `PACKAGE_SPELL_ID_FLOOR`..=`PACKAGE_SPELL_ID_CEIL`; for items, +`PACKAGE_ITEM_ID_FLOOR`..=`PACKAGE_ITEM_ID_CEIL`; for quests, +`PACKAGE_QUEST_ID_FLOOR`..=`PACKAGE_QUEST_ID_CEIL`; for loot rows, +`PACKAGE_LOOT_ID_FLOOR`..=`PACKAGE_LOOT_ID_CEIL`; for `game_creature_spell` rows, +`PACKAGE_CAST_ID_FLOOR`..=`PACKAGE_CAST_ID_CEIL`; for `game_trainer_spell` rows, +`PACKAGE_TRAINER_ID_FLOOR`..=`PACKAGE_TRAINER_ID_CEIL`; for the insertable gossip rows, +`PACKAGE_GOSSIP_ID_FLOOR`..=`PACKAGE_GOSSIP_ID_CEIL`; for the insertable globals rows, +`PACKAGE_GLOBALS_ID_FLOOR`..=`PACKAGE_GLOBALS_ID_CEIL`; for `game_spell_learn` rows, +`PACKAGE_SPELLMETA_ID_FLOOR`..=`PACKAGE_SPELLMETA_ID_CEIL`; for the creature template and +spawn rows, `PACKAGE_CREATURE_ID_FLOOR`..=`PACKAGE_CREATURE_ID_CEIL`; for the gameobject +template, trap and spawn rows, +`PACKAGE_GAMEOBJECT_ID_FLOOR`..=`PACKAGE_GAMEOBJECT_ID_CEIL`; for the broadcast text, +summon placement and quest event requirement rows, +`PACKAGE_CREATURE_AI_ID_FLOOR`..=`PACKAGE_CREATURE_AI_ID_CEIL`. `game_spell_chain` and +`game_spell_proc_event` key on a spell identifier rather than one of their own, so an insert +there takes the Package spell band. Updates can tune imported rows, but every operation refuses +fixture-reserved identifiers. See `ids` for the bands and their formulas. + +# The other artifact + +`script` holds the **Script Artifact**: the whole Runtime Scripts one Package ships. It is a +second kind rather than more tables in the claim schema because a Runtime Script has no base +import behind it, so a Package owns the whole row and two Packages meeting on one is a collision +rather than a merge. + +Both kinds live in one Package's generated directory, so a reader that walks `*.json` meets +both. `artifact_kind` reads the root `kind` member alone and says which is which; each +parser refuses the other kind by name rather than complaining about its members. diff --git a/docs/realm-loot-routing.md b/docs/realm-loot-routing.md new file mode 100644 index 000000000..1868d9218 --- /dev/null +++ b/docs/realm-loot-routing.md @@ -0,0 +1,25 @@ +# Realm loot routing + +`gateway/src/world/loot.rs` routes loot-roll votes and promotes locally created rolls to Realm-core. +It uses the existing `WorldStore` seam and the same in-memory topology as party routing tests. + +`game_loot_roll` and `game_loot_roll_vote` are authoritative on Realm-core with Group membership. +A participant who crosses Shards during a roll can still vote through their current Gateway. + +Creature death creates the roll in its own transaction on the World Shard. The Module cannot write +another database. That local roll is a staging copy. `relay_tick` promotes it to Realm-core and +clears the staging copy so only one database votes and resolves it. The initial `ROLL_START` +message reaches the nearby participants through their existing local relay. + +`run_vote` flushes pending promotions before issuing the vote on Realm-core. `party::run` also +flushes them before leave and uninvite requests. Those requests can disband a Group, whose removal +transaction must see every existing roll to resolve it immediately. A periodic poll alone leaves +a window between creature death and promotion where disband could miss the roll. + +The corpse's loot rows and item grant remain on the World Shard. After observing `ROLL_WON`, the +Gateway calls `settle_loot_roll` on connected World Shards. The Module's withheld-item check makes +the request harmless on a Shard that does not hold the corpse and prevents duplicate grants. + +The 200ms poll reads each Coordinator's current connection and carries the database routing context +needed for promotion and settlement. It runs without a player session and bounds ordinary relay +latency. Synchronous promotion before votes and membership requests supplies the ordering guarantee. diff --git a/docs/runtime-script-host.md b/docs/runtime-script-host.md new file mode 100644 index 000000000..65fe73378 --- /dev/null +++ b/docs/runtime-script-host.md @@ -0,0 +1,63 @@ +# Runtime Script Host + +The Runtime Script Host contains the Lua interpreter used by Runtime Scripts. + +One `RuntimeScriptHost` owns one embedded piccolo interpreter and a compiler cache. The cache holds +compiled chunks only, keyed by a hash of the source, so it can never hand back stale code for +an edited script and it holds no Lua state between invocations. + +Every invocation gets: + +* a fresh environment table with its own standard-library tables, so nothing a script assigns + survives it; +* a `FUEL_BUDGET_PER_INVOCATION` of metered interpreter work, plus + `MAX_STEPS_PER_INVOCATION` as the + stall guard for the case where a step burns no fuel at all; +* a staging buffer. A host operation a script calls does not touch the world; it appends a + `StagedEffect`. Only a fully successful invocation returns `StagedEffects`, which the + caller commits through core gameplay operations. A syntax, runtime, or fuel failure returns a + `ScriptDiagnostic` instead and every effect staged by that invocation is dropped with it. + +`run_event` is the failure boundary: it invokes each Runtime Script in turn, commits the ones +that succeeded, and collects a bounded diagnostic for the ones that did not. One bad script +never stops the next one, and never stops the core work that follows. + +# Host operations + +An Invocation sees one global per host operation and one `event` table: + +```lua +event.name -- the event label, a string +event.actor -- the Entity Handle that caused the event, or nil +event.target -- the Entity Handle the event acted on, or nil + +-- An Entity Handle's readable fields, snapshotted when the Invocation started: +e.name, e.is_player, e.level, e.health, e.max_health, e.map_id, e.x, e.y, e.z + +heal(entity, amount) -- stage a heal, crediting heal-threat to event.actor +send_chat(player, text) -- stage a System Message to one online player +grant_xp(player, amount)-- stage an experience grant + +return 42 -- the Script Answer: a number the asking caller reads back +``` + +A chunk that returns a number answers the caller that asked; returning nothing, or anything that +is not a number, answers nothing. Only `ask_event` reads an answer. `run_event` discards it, +because a core hook event has no caller waiting on one. + +An Entity Handle is opaque: a script cannot read a guid out of it and cannot mint one, so the +only entities a Runtime Script can act on are the ones the Host resolved for that Invocation. +Every readable field is a snapshot taken before the script ran; writing to one changes nothing. + +A host operation called with a missing entity, the wrong type, an out-of-range amount, or past +the staging cap raises a Lua error naming the call and the fault. The Invocation returns a +`ScriptDiagnostic`, drops all staged effects, and leaves the reducer running. + +# What an Invocation is allowed + +The environment is built from `ALLOWED_GLOBALS` and `ALLOWED_LIBRARY_MEMBERS`, name by name, +into tables made for that Invocation. A name outside those lists is nil inside a Runtime Script, +and a write to an allowed library table is invisible to the next Invocation. + +The Host provides no durable script storage, event bindings, damage, items, spawning, scheduling, +or database queries. Lua state ends with its Invocation. diff --git a/gateway/src/accept.rs b/gateway/src/accept.rs index 87e27dd1a..d65e60298 100644 --- a/gateway/src/accept.rs +++ b/gateway/src/accept.rs @@ -1,63 +1,4 @@ -//! Accept-loop resource policy. This is the one place that decides whether an `accept(2)` failure -//! ends a listener or costs one connection, and it provides the shared non-waiting capacity that -//! keeps accepted sockets out of Tokio's unbounded blocking-task queue. Root-caused on the -//! mass-session login storm that killed the gateway outright (see below). -//! -//! # The bug this exists to prevent -//! -//! `main` joins the two listener tasks with `tokio::try_join!`, and both accept loops used to write -//! `listener.accept().await?`. tokio retries exactly one errno — `WouldBlock` -//! (`tokio/src/net/tcp/listener.rs`: every other `Err(e)` is `return Poll::Ready(Err(e))`) — so any -//! other errno travelled out of the accept loop, into `try_join!`, out of `main`, and ended the -//! process. On 2026-08-07 that is exactly what happened, and the gateway's last line was: -//! -//! ```text -//! Error: Too many open files (os error 24) -//! ``` -//! -//! One `EMFILE` — a *per-call* condition that says nothing about the listener — took down every -//! session on the realm. `ECONNABORTED` (a peer resetting while queued in the backlog, which is what -//! a synthetic load harness killing its clients produces en masse) would have done the same. -//! -//! # The policy, and why it is shaped as a fatal ALLOWLIST -//! -//! [`classify_accept_error`] names a small, explicit set of errnos as fatal and treats everything -//! else as transient. The list is deliberately in that direction rather than the other: -//! -//! - The fatal set is exactly the errnos that mean **the listening socket itself is unusable**, so -//! retrying could only spin forever. Each is also a condition **no remote peer can induce** — they -//! are local programming/lifetime faults, not traffic. -//! - Everything else — including errnos we have never seen — is about *one connection* or a -//! momentary shortage, and the honest response is to log it loudly and take the next connection. -//! An unknown errno ending the realm is the failure mode we are fixing; an unknown errno costing -//! one connection is not a failure mode at all. -//! -//! This is not "swallow anything". Every transient error is logged at WARN by the caller with its -//! errno text, and [`AcceptBackoff`] bounds the cost of a *permanent* condition we misjudged as -//! transient: the loop degrades to one attempt (and one log line) per second instead of spinning a -//! core at full tilt. A gateway that is loudly degraded beats a gateway that is gone. -//! -//! # Why exactly these four are fatal -//! -//! | errno | what it means for `accept` | why retrying is pointless | -//! |---|---|---| -//! | `EBADF` | the listener fd is not an open descriptor | it will never become one; every retry returns `EBADF` | -//! | `ENOTSOCK` | the fd is open but is not a socket | same fd, same answer, forever | -//! | `EINVAL` | the socket is not listening (or `addrlen` is bogus) | our listener came from `TcpListener::bind`, so this can only mean the fd was replaced underneath us | -//! | `EFAULT` | the address argument is not writable | a bug in tokio or in us; it will not fix itself between iterations | -//! -//! `EOPNOTSUPP` is the interesting **exclusion**. `accept(2)` lists it twice with opposite meanings: as -//! "the socket is not of type `SOCK_STREAM`" (permanent) *and* in the Linux-specific set of -//! already-pending network errors — `ENETDOWN`, `EPROTO`, `ENOPROTOOPT`, `EHOSTDOWN`, `ENONET`, -//! `EHOSTUNREACH`, `EOPNOTSUPP`, `ENETUNREACH` — which the man page says "should be treated like -//! `EAGAIN` by retrying". The permanent reading is impossible for us by construction: this listener -//! is a `TcpListener::bind`, hence `SOCK_STREAM`, hence the pending-network reading is the only one -//! that can apply. So it retries. `EPERM` likewise: on Linux `accept` reports it when a firewall -//! rule forbids *that* connection — a per-connection verdict, not a listener verdict. -//! -//! An `io::Error` carrying **no** raw errno (a synthetic error, never observed from `accept`) is -//! treated as transient for the same reason unknown errnos are: we cannot show it is permanent, and -//! the backoff caps what being wrong costs. +//! Accept-loop resource policy. See `docs/accept-policy.md` for the contract and rationale. use std::io; use std::sync::Arc; @@ -114,9 +55,7 @@ pub enum AcceptOutcome { /// The errnos that mean the *listening socket* is broken rather than one connection. /// -/// Kept as data so the test below can assert the whole set at once, and so adding to it is a -/// visible, reviewable act. See the module docs for why each one is here — and, just as -/// importantly, why `EMFILE`/`ENFILE`/`ECONNABORTED`/`EOPNOTSUPP` are **not**. +/// `docs/accept-policy.md` explains the allowlist and the transient resource errors. const FATAL_ACCEPT_ERRNOS: &[i32] = &[libc::EBADF, libc::ENOTSOCK, libc::EINVAL, libc::EFAULT]; /// Decide whether an accept-path error ends the listener task or one connection. @@ -195,16 +134,11 @@ mod tests { io::Error::from_raw_os_error(errno) } - /// The whole point of this change: the errno that actually killed the realm on 2026-08-07 — and - /// its siblings among the transient errnos this module retries — must cost one connection, not - /// the process. + /// Resource exhaustion and interrupted connections leave the listener available. #[test] fn the_errnos_that_killed_the_realm_are_transient() { for (errno, name) in [ - ( - libc::EMFILE, - "EMFILE — this process is out of fds (the 08-07 death)", - ), + (libc::EMFILE, "EMFILE: this process is out of fds"), (libc::ENFILE, "ENFILE — the system is out of fds"), ( libc::ECONNABORTED, @@ -315,7 +249,6 @@ mod tests { assert_eq!(b.consecutive(), 3); b.record_success(); assert_eq!(b.consecutive(), 0); - // ...and the next lone failure is free again, so one bad connection an hour costs nothing. assert_eq!(b.record_failure(), Duration::ZERO); } diff --git a/gateway/src/codec/addon.rs b/gateway/src/codec/addon.rs index 29a6c0702..857040fa2 100644 --- a/gateway/src/codec/addon.rs +++ b/gateway/src/codec/addon.rs @@ -197,7 +197,7 @@ mod tests { ); } - /// AC (fix #1): the client's `SendAddonMessage` API distributes over PARTY, RAID, GUILD and + /// the client's `SendAddonMessage` API distributes over PARTY, RAID, GUILD and /// OFFICER only. RAID_LEADER and RAID_WARNING chat lines cannot carry addon traffic at all, /// and WHISPER/CHANNEL addon frames are this bridge's own private channels, not shared wire. #[test] @@ -222,10 +222,6 @@ mod tests { assert!(!is_bridge_prefixed("no tab at all")); } - /// AC (fix #1): a RAID addon frame from another addon's prefix is not ours, but its chat type - /// still reaches real players — `gateway/src/world/mod.rs` lets it fall through to the typed - /// reader instead of dropping it. This proves the premise the module doc now states: a typed - /// `CMSG_MESSAGECHAT` on the addon language decodes cleanly, chat type and all. #[test] fn a_raid_addon_frame_from_another_prefix_decodes_through_the_typed_reader() { use wow_world_messages::vanilla::opcodes::ClientOpcodeMessage; @@ -260,7 +256,7 @@ mod tests { } } - /// AC (fix #1): a SAY addon frame is not a typed chat type, so the caller keeps dropping it — + /// a SAY addon frame is not a typed chat type, so the caller keeps dropping it , /// SAY has no chat-kind route to a real player, addon-language or not. #[test] fn a_say_addon_frame_is_still_dropped() { diff --git a/gateway/src/codec/char.rs b/gateway/src/codec/char.rs index 9bdaed436..c41f98df7 100644 --- a/gateway/src/codec/char.rs +++ b/gateway/src/codec/char.rs @@ -39,7 +39,7 @@ pub struct CharacterView { pub guild_id: u32, } -/// Build the `SMSG_CHAR_ENUM` reply for the character-select screen (Phase 3, gateway +/// Build the `SMSG_CHAR_ENUM` reply for the character-select screen, gateway /// translation §4). Each [`CharacterView`] becomes a `wow_world_messages` `Character` block; /// The five appearance bytes a player picks at character creation, bundled so `create_character` /// (dispatch → trait → coordinator) passes one value instead of five positional `u8`s that are @@ -184,12 +184,6 @@ pub fn logout_denied_in_combat() -> SMSG_LOGOUT_RESPONSE { } } -/// The reply to `CMSG_PLAYED_TIME` (`/played`): `total_played_time` is the durable -/// `played_total_secs` plus this session's live elapsed span (so an online player's total keeps -/// ticking without a periodic write); `level_played_time` is not tracked per-level in this slice, so -/// it mirrors the total (matching vanilla's own `/played` fallback shape when level-time is unset). -/// `now_micros` is the caller's current wall-clock reading (unix-epoch micros) so this stays pure/ -/// testable rather than reaching for `SystemTime::now()` internally. pub fn build_played_time( played_total_secs: u32, session_start_micros: u64, diff --git a/gateway/src/codec/combat.rs b/gateway/src/codec/combat.rs index 73c1afb0b..7f3534328 100644 --- a/gateway/src/codec/combat.rs +++ b/gateway/src/codec/combat.rs @@ -267,11 +267,6 @@ pub fn build_spell_go( build_spell_go_outcome(caster_guid, spell_id, hit_target, ammo, false) } -/// The GO for a GROUND-AREA spell (118, Consecration): an EMPTY hit list — the spell hits the -/// ground, nothing "impacts" a unit at cast time (the area's ticks deal the damage; the swirl is -/// the DynamicObject). The generic self-cast fallback put the CASTER in `hits[]` and the 5875 -/// client played the impact animation ON the paladin (user bug). Targets stay SELF (the area is -/// caster-anchored this slice; a dest-clicked area rides cast_spell_at's dest block separately). pub fn build_spell_go_area(caster_guid: u64, spell_id: u32) -> SMSG_SPELL_GO { SMSG_SPELL_GO { cast_item: Guid::new(caster_guid), diff --git a/gateway/src/codec/entity.rs b/gateway/src/codec/entity.rs index e1b3419f5..34e9843fb 100644 --- a/gateway/src/codec/entity.rs +++ b/gateway/src/codec/entity.rs @@ -63,7 +63,7 @@ pub struct EntityView { pub unit_bytes_1: u32, // UNIT_FIELD_BYTES_1; byte 3 carries UNIT_VIS_FLAG_GHOST while a ghost (slice 5) /// `UNIT_FIELD_BYTES_2`; byte 0 is the sheath state (0 stowed / 1 melee drawn / 2 ranged drawn). /// Without it in the CREATE, everyone entering AOI range renders this unit permanently unarmed - /// no matter what it is holding. [#101] + /// no matter what it is holding. pub unit_bytes_2: u32, // The five base attributes (UNIT_FIELD_STAT0..4) for the character sheet; 0 for a creature. pub strength: u32, @@ -322,7 +322,7 @@ pub fn build_create_object( let (facial_hair, pb2_b, pb2_c, pb2_d) = unpack4(entity.player_bytes_2); let (pb3_a, pb3_b, pb3_c, pb3_d) = unpack4(entity.player_bytes_3); let (ub1_a, ub1_b, ub1_c, ub1_d) = unpack4(entity.unit_bytes_1); // ghost vis bit in byte 3 (slice 5) - let (ub2_a, ub2_b, ub2_c, ub2_d) = unpack4(entity.unit_bytes_2); // sheath state in byte 0 (#101) + let (ub2_a, ub2_b, ub2_c, ub2_d) = unpack4(entity.unit_bytes_2); // sheath state in byte 0 // Melee and ranged paperdoll values are stored Module projections. CREATE reads them // unchanged so it agrees with later partial sheet updates. @@ -341,7 +341,7 @@ pub fn build_create_object( .set_unit_baseattacktime(entity.base_attack_time_ms as i32) .set_unit_bytes_1(ub1_a, ub1_b, ub1_c, ub1_d) // ghost render bit (slice 5) // gtker NAMES this setter's params after PLAYER_BYTES_2 (facial hair/rest state) — wrong - // field, right index (164). Byte 0 is the sheath state; the names are noise. [#101] + // field, right index (164). Byte 0 is the sheath state; the names are noise. .set_unit_bytes_2(ub2_a, ub2_b, ub2_c, ub2_d) .set_player_features(skin, face, hair_style, hair_color) .set_player_bytes_2(facial_hair, pb2_b, pb2_c, pb2_d) @@ -485,7 +485,7 @@ pub fn build_create_object( } else { // Creature (Unit): no player descriptor fields. `entry` is REQUIRED so the client can issue // CMSG_CREATURE_QUERY; without it the creature renders nameless. - let (cb2_a, cb2_b, cb2_c, cb2_d) = unpack4(entity.unit_bytes_2); // sheath state in byte 0 (#101) + let (cb2_a, cb2_b, cb2_c, cb2_d) = unpack4(entity.unit_bytes_2); // sheath state in byte 0 let mut unit_builder = UpdateUnit::builder() .set_object_guid(Guid::new(entity.guid)) .set_object_entry(entity.entry as i32) @@ -500,7 +500,7 @@ pub fn build_create_object( .set_unit_nativedisplayid(entity.native_display_id as i32) .set_unit_flags(entity.unit_flags as i32) .set_unit_baseattacktime(entity.base_attack_time_ms as i32) - // Sheath state (#101) — a creature draws its weapon on engage exactly like a player, so + // Sheath state, a creature draws its weapon on engage exactly like a player, so // this belongs on the Unit branch too, not just the player one. .set_unit_bytes_2(cb2_a, cb2_b, cb2_c, cb2_d) .set_unit_dynamic_flags(entity.dynamic_flags as i32) // slice 2: corpse/lootable bits @@ -595,7 +595,7 @@ pub(crate) fn realm_datetime(secs_since_unix_epoch: u64) -> DateTime { let minutes = ((secs_of_day % 3_600) / 60) as u8; let (year, month, month_day) = lyracore_shared::calendar::civil_from_days(days); - // 1970-01-01 (days = 0) was a Thursday, and `Weekday::as_int()` numbers Sunday 0 .. + // 1970-01-01 (days = 0) was a Thursday, and `Weekday::as_int()` numbers Sunday 0.. // Saturday 6 — exactly `(days + 4) % 7` for a non-negative day count. let weekday = Weekday::try_from(((days + 4) % 7) as u32) .expect("(days + 4) % 7 is always 0..=6, a valid Weekday"); @@ -610,7 +610,7 @@ pub(crate) fn realm_datetime(secs_since_unix_epoch: u64) -> DateTime { ) } -/// Emit the post-login SMSG sequence (Phase 4, gateway translation §5) to the owner, in the +/// Emit the post-login SMSG sequence, gateway translation §5) to the owner, in the /// order the client expects — `SMSG_LOGIN_VERIFY_WORLD` must precede `SMSG_TUTORIAL_FLAGS`, /// and is sent ONLY for a [`WorldEntry::FreshLogin`] (see the enum's doc for why a world-port /// re-entry must omit it). @@ -734,7 +734,7 @@ pub fn login_sequence_messages( // `reputation_index` slot (0..63) — the SAME Faction.dbc ReputationListID the live // SET_FACTION_STANDING relay addresses (see build_set_faction_standing_raw's crash note). // HARD GUARDRAIL: index by reputation_index, NEVER faction_id — faction_id indexed past the - // client's 64-slot array caused the McBride ERROR #132 crash. Slots with no persisted row stay + // client's 64-slot array caused the McBride ERROR crash. Slots with no persisted row stay // Neutral/0 (the prior stub value), matching a faction the player has never gained rep with. factions: { let mut slots: Vec = (0..64) @@ -777,7 +777,7 @@ pub fn login_sequence_messages( Ok(msgs) } -/// Build `SMSG_DESTROY_OBJECT` for a guid leaving view (Phase 7). Vanilla carries just the +/// Build `SMSG_DESTROY_OBJECT` for a guid leaving view. Vanilla carries just the /// guid (no `is_on_death`). pub fn build_destroy_object(guid: u64) -> SMSG_DESTROY_OBJECT { SMSG_DESTROY_OBJECT { @@ -841,7 +841,7 @@ const SMSG_SET_FACTION_STANDING_OPCODE: u16 = 0x0124; /// (a SMALL 0..63 index into the client's fixed rep array), NOT the faction id. SMSG_INITIALIZE_FACTIONS sets up /// 64 slots; SET_FACTION_STANDING addresses ONE by that index. We were sending `faction.as_int()` = the faction /// id (Stormwind 72), so the client indexed slot 72 — past the 64-slot array → null faction record → `mov -/// esi,[ebx]` with ebx=0 → ERROR #132 ACCESS_VIOLATION (the client crash dump's args carried opcode 0x124 + the +/// esi,[ebx]` with ebx=0 → ERROR ACCESS_VIOLATION (the client crash dump's args carried opcode 0x124 + the /// standing 3175, confirming this packet). The earlier u16→u32 width fix was a real-but-insufficient symptom fix; /// the VALUE was always wrong. `reputation_index` now comes straight off the row (the module stamps it from /// game_faction at grant time); callers pass `< 0` rows nowhere (the relay skips them — no rep bar to address). diff --git a/gateway/src/codec/gameobject.rs b/gateway/src/codec/gameobject.rs index 07707bdf9..68efef584 100644 --- a/gateway/src/codec/gameobject.rs +++ b/gateway/src/codec/gameobject.rs @@ -17,9 +17,9 @@ pub struct GameObjectView { pub state: u8, pub type_id: u8, pub display_id: u32, - /// The cmangos spawn quaternion (issue #515) — `GAMEOBJECT_ROTATION`, the field the 5875 client + /// The cmangos spawn quaternion, `GAMEOBJECT_ROTATION`, the field the 5875 client /// actually renders a static prop's orientation from. All-zero means "no quaternion stored" - /// (every pre-#515 row and every hand-seeded fixture): `build_gameobject_rotation_values` derives + /// (every row without rotation data and every hand-seeded fixture): `build_gameobject_rotation_values` derives /// a yaw-only fallback from `orientation` in that case rather than sending a degenerate rotation. pub rotation_0: f32, pub rotation_1: f32, @@ -92,22 +92,13 @@ pub fn build_gameobject_create_object(go: &GameObjectView) -> SMSG_UPDATE_OBJECT } } -/// Build the raw `GAMEOBJECT_ROTATION` VALUES update (issue #515) — the 4-float spawn quaternion the +/// Build the raw `GAMEOBJECT_ROTATION` VALUES update, the 4-float spawn quaternion the /// client actually orients a static prop's model from. gtker's typed builder only exposes /// `set_gameobject_rotation(f32)`, which reaches slot 0 alone (the same descriptor-setter wall as the /// multi-aura array), so all 4 slots (rot0..3, wire index `GAMEOBJECT_ROTATION`..+3) ride the /// hand-rolled raw encoder instead. Not a partial UNIT/PLAYER update — `GAMEOBJECT_ROTATION` (10) never /// collides with `OBJECT_FIELD_TYPE` (2), so `build_values_update_raw`'s debug_assert is inert here by /// construction, same as every other GAMEOBJECT field. -/// -/// Trap this exists to dodge: an all-zero stored quaternion (every row imported before this migration, -/// and every hand-seeded fixture that never set these) is NOT a valid "identity" rotation to send -/// verbatim — a real vanilla spawn's quaternion is never exactly (0,0,0,0) (that's a degenerate, -/// zero-magnitude quaternion; a true identity is (0,0,0,1)). Sending it as-is renders the client's -/// DEFAULT orientation regardless of `orientation`, which is the exact bug #515 reports. So the -/// all-zero case DERIVES a yaw-only quaternion from `orientation` (`rot2 = sin(o/2)`, `rot3 = cos(o/2)`, -/// matching vanilla's Z-axis-only yaw convention — rot0/rot1 stay 0, i.e. no terrain pitch/roll, which -/// is the best a bare `orientation` float can express) instead. pub fn build_gameobject_rotation_values(go: &GameObjectView) -> (u16, Vec) { let (rot0, rot1, rot2, rot3) = if go.rotation_0 == 0.0 && go.rotation_1 == 0.0 @@ -326,7 +317,7 @@ mod tests { #[test] fn gameobject_rotation_values_derives_yaw_only_from_orientation_when_quaternion_is_all_zero() { - // Every pre-#515 row and every hand-seeded fixture stores an all-zero quaternion — that must + // Every row without rotation data and every hand-seeded fixture stores an all-zero quaternion, that must // NOT ride verbatim (a real spawn's quaternion is never exactly (0,0,0,0); sending it as-is // is the exact tilted-bench bug this issue reports). Derive rot2/rot3 = sin(o/2)/cos(o/2) // (Z-axis-only yaw) instead, leaving rot0/rot1 (terrain pitch) at 0. diff --git a/gateway/src/codec/loot.rs b/gateway/src/codec/loot.rs index 52f7892f3..039ce261d 100644 --- a/gateway/src/codec/loot.rs +++ b/gateway/src/codec/loot.rs @@ -105,13 +105,6 @@ pub fn build_loot_start_roll( } } -/// The wire `roll_number` for one vote: vanilla has NO separate `auto_pass` field (that's a -/// TBC/WRATH-only wire addition — `wow_world_messages::vanilla::SMSG_LOOT_ROLL` genuinely lacks it, -/// confirmed against the vendored crate) — a PASS (manual OR auto-at-deadline; the two are -/// indistinguishable on the vanilla wire, `[V]`, no live client to confirm the exact convention) -/// is instead signaled by `roll_number > 127` per the field's own doc comment ("> 127: you passed"). -/// `128` is the value cmangos/vmangos use. A NEED/GREED vote's real 1-100 roll passes through -/// unchanged. Pure — unit-tested. pub(crate) fn wire_roll_number(vote: u8, rolled: u8) -> u8 { if vote == vote_kind::PASS { 128 diff --git a/gateway/src/codec/member_stats.rs b/gateway/src/codec/member_stats.rs index 8394ca9bd..f2583b663 100644 --- a/gateway/src/codec/member_stats.rs +++ b/gateway/src/codec/member_stats.rs @@ -269,13 +269,6 @@ impl MemberStats { } } -/// The fields that differ between what a viewer last received and `current`. `None` means the -/// viewer holds nothing, so every field goes, unconditionally, pet fields included even with no -/// pet: cmangos marks a joining or newly-online member's whole update flag set with -/// `GROUP_UPDATE_FULL` (cm:Group.cpp:306-307, 746-747), and the next heartbeat's delta packet -/// carries that mask as-is, not the pet-omitting mask the explicit FULL answer builds. A -/// power-type change also resends both power values, on the member and on its pet alike -/// (cm:GroupHandler.cpp:589-593). pub fn stats_delta(previous: Option<&MemberStats>, current: &MemberStats) -> GroupUpdateMask { let Some(previous) = previous else { return GroupUpdateMask::FULL; diff --git a/gateway/src/codec/movement.rs b/gateway/src/codec/movement.rs index 83cbb9a47..b885a45d9 100644 --- a/gateway/src/codec/movement.rs +++ b/gateway/src/codec/movement.rs @@ -1,11 +1,7 @@ -//! Movement wire mapping: the inbound `MSG_MOVE_*` relay (peer animation), the server-driven -//! `SMSG_MONSTER_MOVE` leg, and the `MovementInfo` ↔ raw-bytes carrier used to ferry a movement -//! block across the DB. Pure code-motion out of `mod.rs`. - use super::*; /// The opcode of an inbound movement message, if it's one of the slice's relayed `MSG_MOVE_*` -/// (Phase 5). Used to record + relay the move under the *same* opcode. +/// Records and relays movement under the same opcode. pub fn relayed_move_opcode(msg: &ClientOpcodeMessage) -> Option { use movement_opcodes as m; Some(match msg { @@ -30,7 +26,7 @@ pub fn relayed_move_opcode(msg: &ClientOpcodeMessage) -> Option { }) } -/// Re-emit a relayed `MSG_MOVE_*` to an observer (Phase 6): the **same** opcode, the mover's +/// Re-emit a relayed `MSG_MOVE_*` to an observer: the **same** opcode, the mover's /// (packed) guid, and the carried `MovementInfo` verbatim — which is what makes the peer client /// animate run/walk/turn rather than teleport. Returns `None` for a non-relayed opcode. The /// crate packs the guid and frames the header on write (`ServerOpcodeMessage::write_*_server`). @@ -270,7 +266,7 @@ pub fn build_force_run_speed(guid: u64, speed: f32) -> SMSG_FORCE_RUN_SPEED_CHAN } } -/// Build `SMSG_MONSTER_MOVE` for one server-driven creature leg (Phase N2): a straight walk from +/// Build `SMSG_MONSTER_MOVE` for one server-driven creature leg: a straight walk from /// `start` to `dest` over `duration_ms`. EXACTLY ONE destination point — the crate writes a single /// spline point absolute, avoiding its multi-point packed-offset encoding (not 1.12-compatible). /// `spline_id` must strictly increase per creature or the client ignores the new spline. @@ -374,7 +370,7 @@ pub fn build_taxi_move_raw( Some((0x00DD, body)) } -/// Build `SMSG_MONSTER_MOVE` as a FACING-ONLY packet (#518): `mover_guid` does not move — `pos` is +/// Build `SMSG_MONSTER_MOVE` as a FACING-ONLY packet : `mover_guid` does not move, `pos` is /// both the spline point and its sole (degenerate) destination, `duration` 0 — but its heading /// changes to `angle_rad`. This is the wire tool the issue calls out as "already exists, unwired": /// gtker 0.3 models 1.12's `FacingAngle` `MonsterMoveType` (`spline_point`/`splines` framing diff --git a/gateway/src/codec/quest.rs b/gateway/src/codec/quest.rs index 3fb1a404a..67ccb98c4 100644 --- a/gateway/src/codec/quest.rs +++ b/gateway/src/codec/quest.rs @@ -11,7 +11,6 @@ //! //! NOT here: the quest-LOG window (`PLAYER_QUEST_LOG_*` descriptor fields) + `CMSG_QUEST_QUERY` text — //! those ride the UpdateMask wall (the hand-rolled `update_mask` encoder lacks the quest-log indices) -//! and are the deferred Phase-2 sub-slice. use super::*; use wow_world_base::shared::quest_party_message_vanilla_tbc::QuestPartyMessage; diff --git a/gateway/src/codec/tests.rs b/gateway/src/codec/tests.rs index 043907c68..57af8b76f 100644 --- a/gateway/src/codec/tests.rs +++ b/gateway/src/codec/tests.rs @@ -1,6 +1,4 @@ -//! Unit + golden-vector tests for the wire codec. Hoisted out of `mod.rs` (was an 819-line -//! trailing `#[cfg(test)] mod tests` block) so the production codec reads on its own; this is -//! pure code-motion, byte-identical to the previous inline module. +//! Unit and golden-vector tests for the wire codec. use super::*; use std::time::{SystemTime, UNIX_EPOCH}; @@ -44,7 +42,7 @@ fn warrior_entity() -> EntityView { fn set_faction_standing_raw_sends_the_rep_index_not_the_faction_id() { // 5875 layout: count(u32) + ReputationListID(u32) + standing(u32) = 12 bytes. The CRASH FIX: the middle // field is the rep-list INDEX (Stormwind = 19), NOT the faction id (72). Sending 72 indexed past the - // client's 64-slot rep array → null deref → ERROR #132. The caller passes game_faction.reputation_index. + // client's 64-slot rep array → null deref → ERROR. The caller passes game_faction.reputation_index. let (opcode, body) = build_set_faction_standing_raw(19, 500).expect("a valid rep-index always builds"); assert_eq!(opcode, 0x0124); @@ -1437,8 +1435,6 @@ fn worn_shortsword() -> ItemTemplateView { } } -/// A simple chest armor with known stat values — used to pin that stat_armor + equip stats -/// reach the wire response (not zeroed out by the codec). fn blackrock_gauntlets() -> ItemTemplateView { ItemTemplateView { entry: 1448, @@ -1533,10 +1529,7 @@ fn item_query_response_armor_and_stats_reach_wire() { match ServerOpcodeMessage::read_unencrypted(&mut buf.as_slice()).unwrap() { ServerOpcodeMessage::SMSG_ITEM_QUERY_SINGLE_RESPONSE(m) => { let found = m.found.expect("Blackrock Gauntlets must be found"); - assert_eq!( - found.armor, 105, - "stat_armor must reach the wire (was 0 before fix)" - ); + assert_eq!(found.armor, 105, "stat_armor must reach the wire"); // First stat slot must carry Strength=3; remaining slots padded with Mana=0. assert_eq!( found.stats[0].stat_type, @@ -1853,7 +1846,7 @@ fn a_copied_letters_create_block_carries_its_item_text_id() { match &build_item_create_object(&plain).objects[0] { Object::CreateObject2 { mask2: UpdateMask::Item(it), - .. +.. } => assert_eq!( it.item_item_text_id(), None, @@ -1939,7 +1932,7 @@ fn skill_block_reads_learned_rows_override_and_append() { #[test] fn create_object_carries_the_sheath_state_for_player_and_creature() { - // #101: the CREATE is how a peer entering AOI range learns a unit's sheath state. Omit + // the CREATE is how a peer entering AOI range learns a unit's sheath state. Omit // UNIT_FIELD_BYTES_2 and everyone who walks up to a player with a drawn sword sees them // empty-handed until the next toggle. Byte 0 is the state; bytes 1-3 ride along untouched. let mut e = warrior_entity(); @@ -2283,7 +2276,7 @@ fn gossip_message_body_is_exact_5875_layout() { let guid = 0xF130_0000_0000_0001u64; let msg = SMSG_GOSSIP_MESSAGE { guid: Guid::new(guid), - title_text_id: GOSSIP_GREETING_TEXT_ID, // == 1 after the fix (low, in-range npc_text id) + title_text_id: GOSSIP_GREETING_TEXT_ID, // Low, in-range npc_text id. gossips: vec![GossipItem { id: 0, item_icon: 1, @@ -2311,7 +2304,7 @@ fn gossip_message_body_is_exact_5875_layout() { let mut want: Vec = Vec::new(); want.extend_from_slice(&guid.to_le_bytes()); // guid: u64 - want.extend_from_slice(&1u32.to_le_bytes()); // title_text_id: u32 (== 1, the fix) + want.extend_from_slice(&1u32.to_le_bytes()); // title_text_id: u32. want.extend_from_slice(&1u32.to_le_bytes()); // amount_of_gossip_items: u32 // gossip item: id u32 | icon u8 | coded u8 | message CString — NO BoxMoney/BoxText (those are TBC) want.extend_from_slice(&0u32.to_le_bytes()); // id @@ -3505,7 +3498,7 @@ fn monster_move_run_flag_selects_the_run_spline_bit() { #[test] fn monster_move_facing_carries_the_angle_and_a_degenerate_single_point_spline() { - // #518: a stand-and-swing creature turns to face its target without moving. The wire shape must + // a stand-and-swing creature turns to face its target without moving. The wire shape must // be the `FacingAngle` variant (the client's ONLY source of a non-moving heading change), zero // duration, and a single-point spline at the mover's own position (there IS no destination). let pos = Vector3d { diff --git a/gateway/src/codec/update_mask.rs b/gateway/src/codec/update_mask.rs index e15ede5b7..f8f9080c3 100644 --- a/gateway/src/codec/update_mask.rs +++ b/gateway/src/codec/update_mask.rs @@ -33,7 +33,7 @@ use std::collections::BTreeMap; /// Vanilla (build 5875) UNIT/PLAYER object update-field indices, each a u32 slot. Every number is -/// the literal `set_int(N, ..)` / `set_bytes(N, ..)` argument from `wow_world_messages`' setters, so +/// the literal `set_int(N,..)` / `set_bytes(N,..)` argument from `wow_world_messages`' setters, so /// the byte-equivalence tests cross-check them against gtker's serializer. /// /// This is the reference layout for the whole vanilla descriptor, not just the fields a caller @@ -88,7 +88,7 @@ pub mod idx { /// `UNIT_FIELD_BYTES_2` — packed; BYTE 0 is the sheath state (0 stowed / 1 melee / 2 ranged), the /// field `CMSG_SETSHEATHED` drives. Vanilla 1.12 index 164, cross-checked against gtker /// `set_unit_bytes_2 → set_bytes(164, …)`. NOT `PLAYER_BYTES_2` (194) — different field, and - /// gtker labels this setter's params with PLAYER_BYTES_2's names, which is a red herring. [#101] + /// gtker labels this setter's params with PLAYER_BYTES_2's names, which is a red herring. pub const UNIT_BYTES_2: u16 = 164; /// `UNIT_FIELD_STAT0..4` — STR, AGI, STA, INT, SPI (add 0..4). pub const UNIT_STAT0: u16 = 150; @@ -97,7 +97,7 @@ pub mod idx { /// Slot N (0-indexed): `CONTAINER_FIELD_SLOT_1 + N * 2`. The gtker vanilla builder only exposes /// `set_container_slot_1` (this index); slots 1+ require the hand-rolled raw encoder (same /// 'gtker descriptor-setter wall' as multi-aura). Cross-checked against `wow_world_messages` - /// vanilla `UpdateContainerBuilder::set_container_slot_1 → set_guid(50, ..)`. + /// vanilla `UpdateContainerBuilder::set_container_slot_1 → set_guid(50,..)`. pub const CONTAINER_FIELD_SLOT_1: u16 = 50; /// `PLAYER_DUEL_ARBITER` — the duel flag gameobject guid, u64 at slots 188 and 189. @@ -135,8 +135,7 @@ pub mod idx { /// `GAMEOBJECT_ROTATION` — a GAMEOBJECT-descriptor field (its own index space, separate from /// UNIT/PLAYER above), 4 consecutive f32 slots (rot0..3, this + 0..=3). Cross-checked against /// `wow_world_messages` vanilla `UpdateGameObjectBuilder::set_gameobject_rotation` → `set_float(10, - /// ..)` — gtker's typed setter only reaches slot 0 (the descriptor-setter wall, same as multi-aura), - /// so all 4 slots ride the hand-rolled raw encoder (issue #515). `GAMEOBJECT_STATE` follows + /// so all 4 slots ride the hand-rolled raw encoder. `GAMEOBJECT_STATE` follows /// immediately at 14, confirming the 4-slot width. pub const GAMEOBJECT_ROTATION: u16 = 10; } diff --git a/gateway/src/codec/values.rs b/gateway/src/codec/values.rs index a69c0f29e..53392001c 100644 --- a/gateway/src/codec/values.rs +++ b/gateway/src/codec/values.rs @@ -6,14 +6,6 @@ use super::*; -/// Own the partial-VALUES `dirty_reset` ritual for a UNIT mask, ONCE (review finding D1). Every -/// `build_*_values` unit builder repeats the same crash-critical discipline: build the mask (whose -/// `new()` force-seeds `OBJECT_FIELD_TYPE` dirty), `finalize()`, `dirty_reset()` to STRIP that seeded -/// TYPE bit, then re-apply ONLY the changed field(s) so the wire carries them alone and NEVER -/// re-sends `OBJECT_FIELD_TYPE` (re-sending it crashes the 5875 client at null+0x110 — see -/// `build_health_values` for the full root-cause). `apply` sets just the caller's field(s) on the -/// reset mask; the output bytes are identical to the pre-extraction inline form (pinned by the codec -/// golden/crash-trap tests). Do NOT migrate this onto the raw encoder. pub(super) fn unit_values(guid: u64, apply: impl FnOnce(&mut UpdateUnit)) -> SMSG_UPDATE_OBJECT { let mut unit = UpdateUnit::builder().finalize(); unit.dirty_reset(); @@ -27,12 +19,6 @@ pub(super) fn unit_values(guid: u64, apply: impl FnOnce(&mut UpdateUnit)) -> SMS } } -/// Own the partial-VALUES `dirty_reset` ritual for a PLAYER mask, ONCE (review finding D1) — the -/// player-shaped twin of [`unit_values`]. Same discipline: finalize → `dirty_reset()` (strips the -/// `new()`-seeded `OBJECT_FIELD_TYPE` dirty bit) → re-apply ONLY the changed field(s), so the wire -/// never re-sends `OBJECT_FIELD_TYPE` (which strips the PLAYER bit on the player's OWN object and -/// crashes the 5875 client at null+0x110). Byte-identical to the pre-extraction inline form (pinned -/// by the codec golden/crash-trap tests). Do NOT migrate this onto the raw encoder. fn player_values(guid: u64, apply: impl FnOnce(&mut UpdatePlayer)) -> SMSG_UPDATE_OBJECT { let mut player = UpdatePlayer::builder().finalize(); player.dirty_reset(); @@ -223,7 +209,7 @@ pub fn build_mount_display_values(guid: u64, mount_display_id: u32) -> SMSG_UPDA /// moment it happens — the `CMSG_SETSHEATHED` a client sends on `Z`. Unit mask, not player-gated: a /// creature draws its weapon on engage too. Same `dirty_reset` discipline as its siblings (the wire /// carries only this field, never OBJECT_FIELD_TYPE). Where a stowed weapon HANGS is a different -/// field entirely — the per-item `sheathe_type` in the item query. [#101] +/// field entirely, the per-item `sheathe_type` in the item query. pub fn build_sheath_values(guid: u64, unit_bytes_2: u32) -> SMSG_UPDATE_OBJECT { let (b2a, b2b, b2c, b2d) = unpack4(unit_bytes_2); unit_values(guid, |unit| { @@ -333,13 +319,13 @@ pub fn build_armor_values(guid: u64, total: [u32; 7], pos_buff: u32) -> SMSG_UPD } /// The character-sheet paperdoll numbers — a plain READ of `module::spell::recompute_sheet`'s output -/// (#517; `stdb::armor::sheet_stats` fetches the row), rendered by [`build_sheet_stats_values`]. +/// (`stdb::armor::sheet_stats` fetches the row), rendered by [`build_sheet_stats_values`]. /// `strength`/`agility`/`stamina`/`intellect`/`spirit` are the EFFECTIVE attribute (base + bonus, the /// white total `UNIT_FIELD_STAT0..4` wants); `*_bonus` is the SIGNED aura+gear(+enchant) delta the /// module already folded — split into the green/red paperdoll halves below via plain sign arithmetic, /// not a second aura read. `attack_power` is the stat-derived base AP; `ap_mods` is the `A_MOD_COMBAT(ATTACK_POWER)` /// aura portion alone (Battle Shout) — vanilla renders those through two different wire fields. -/// `crit_pct` (#532) is `module::combat::effective_crit_bp`/100.0 — the SAME basis-point value the +/// `crit_pct` is `module::combat::effective_crit_bp`/100.0, the SAME basis-point value the /// swing table rolls against, converted to the float percent `PLAYER_CRIT_PERCENTAGE` wants; no /// second crit formula lives on the gateway. pub struct SheetStatsValues { @@ -390,7 +376,7 @@ pub fn build_sheet_stats_values(guid: u64, s: &SheetStatsValues) -> SMSG_UPDATE_ p.set_player_field_negstat4(s.spi_bonus.min(0)); p.set_unit_attack_power(s.attack_power as i32); // UNIT_FIELD_ATTACK_POWER_MODS packs two UNSIGNED shorts (pos, neg-as-magnitude), mirroring - // mangos's `SetInt16Value(field, 0/1, ..)` — never a signed short (a negative AP debuff isn't + // mangos's `SetInt16Value(field, 0/1,..)`, never a signed short (a negative AP debuff isn't // wired yet; `ap_mods` is currently always ≥0 from Battle Shout, so `neg` is 0 in practice). p.set_unit_attack_power_mods(s.ap_mods.max(0) as u16, (-s.ap_mods).max(0) as u16); p.set_unit_mindamage(s.dmg_min as f32); @@ -636,11 +622,7 @@ pub fn build_levelup_values( } player.set_player_xp(xp as i32); player.set_player_next_level_xp(next_level_xp as i32); - // PLAYER_CHARACTER_POINTS1 = free talent points earned so far (level − 9, floor 0). - // Only non-zero from L10 onward (1 point per level starting at 10). The CREATE packet sets - // this too, but without a mid-session VALUES update the talent pane stays at 0 until relog. - // NOTE: does NOT subtract already-spent points (no game_character_talent read here) — that - // refinement is a separate work item. + player.set_player_character_points1((level as i32 - 9).max(0)); }) } diff --git a/gateway/src/config.rs b/gateway/src/config.rs index 0b78c5dca..36445880f 100644 --- a/gateway/src/config.rs +++ b/gateway/src/config.rs @@ -27,13 +27,6 @@ pub struct GatewayConfig { pub module_name: String, /// Auth token for the privileged coordination connection (reads account/session). pub coordinator_token: Option, - /// This gateway PROCESS's own identity (issue #308) — `LYRACORE_GATEWAY_ID`, default - /// `:`. Exists so a load sample this process writes onto realm-core - /// (`load_sample::sample_and_record` → `record_shard_load`) can be told apart from another - /// gateway process's sample for the SAME shard, instead of one clobbering the other - /// (`docs/region-sharding.md`'s "Load sampling" section). Also the natural place to hang a - /// per-process label on the other per-process health signals (`MOTIONSTAT`/`AOISTAT`) later — - /// not done here, out of this issue's scope. pub gateway_id: String, /// Shared non-waiting gate for blocking logon and World Session tasks. Its size mirrors the /// Tokio blocking-pool ceiling configured before this value is built. @@ -369,21 +362,12 @@ impl ShardMap { /// whichever database owns that instance, when the gateway mirrors the id there /// (`ensure_instance`). So: /// - /// - [`InstanceHosting::NoHost`] — the owning database has `hosts_instances = false`. Every - /// dungeon is created as a LEASE with **0 entities** and nothing, anywhere, will ever spawn its - /// population. Measured as 8 consecutive empty instances and 4 bot-test failures - /// that read as gameplay regressions. FATAL — a realm whose dungeons are all empty rooms should - /// not come up quietly. - /// - [`InstanceHosting::LoadNotMoved`] — the DEFAULT database hosts populations while a dungeon - /// map's instances are owned elsewhere. The run works; it just spawns ~207 creatures + 28 - /// gameobject copies on the world writer and evicts them again after the transfer, i.e. exactly - /// the load Phase A exists to remove. A WARNING, and — unlike the old reminder, which fired on - /// every sharded startup regardless of the flag — it now fires only when the flag really is - /// wrong. + /// - [`InstanceHosting::NoHost`] means the owning database refuses population hosting. + /// It is fatal because every dungeon would remain an empty lease. + /// - [`InstanceHosting::LoadNotMoved`] means the default database builds population for + /// a dungeon owned elsewhere. Transfer still works, but the unused population wastes work. /// - /// Everything else is [`InstanceHosting::Consistent`] and says nothing at all. In particular the - /// ordinary single-database realm (no `LYRACORE_SHARD_MAP`, flag at its `true` default) is silent, and - /// so is a correctly-configured Phase A deployment. + /// A correctly configured Realm is silent on either topology. /// /// # Inputs /// @@ -466,8 +450,7 @@ impl ShardMap { dungeon portal's areatrigger runs on {0} — and {0}'s \ `game_config.hosts_instances` is still TRUE, so every entry spawns the whole \ population (~207 creatures + 28 gameobject copies) on the world writer and \ - evicts it again after the transfer. The run works; the load Phase A exists to \ - remove does not go away, and nothing else will tell you. Fix: `spacetime sql \ + evicts it again after the transfer. The run works, but the unused population wastes work. Fix: `spacetime sql \ {0} \"UPDATE game_config SET hosts_instances = false WHERE id = 0\"`.", self.default_db )); @@ -495,11 +478,7 @@ impl ShardMap { /// The startup verdict of [`ShardMap::check_instance_hosting`] — will this realm's dungeon /// populations actually be spawned, and on the right database? /// -/// Exactly one variant means "correctly configured", and it carries no message: the ordinary -/// single-database realm and a correctly-configured Phase A deployment are both SILENT. That is -/// deliberate — the reminder this check replaced warned on every sharded startup whether or not -/// anything was wrong, the operator confirmed it firing on a correct gateway, and a warning that -/// always fires gets filtered, which defeats the one startup it needed to catch. +/// Correct hosting carries no message on either topology. #[derive(Clone, Debug, PartialEq)] pub enum InstanceHosting { /// Every dungeon map's instances land on a database that hosts populations, and no database @@ -636,7 +615,7 @@ fn parse_shard_rule(rule: &str) -> Option { Some(bucket.parse::().ok()?) }; if bucket.is_some_and(|b| b >= INSTANCE_BUCKETS) { - return None; // a bucket outside the modulus can never match — that's a typo, not a rule + return None; } Some(ShardRule { map_id, @@ -776,10 +755,10 @@ fn parse_admission_limit(name: &str, raw: Option<&std::ffi::OsStr>) -> anyhow::R return Ok(0); }; raw.to_str() - .map(str::trim) - .filter(|value| !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit())) - .and_then(|value| value.parse().ok()) - .ok_or_else(|| { +.map(str::trim) +.filter(|value| !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit())) +.and_then(|value| value.parse().ok()) +.ok_or_else(|| { anyhow::anyhow!( "invalid {name}={raw:?}: expected a decimal integer from 0 to {}, with 0 for unlimited", usize::MAX @@ -809,7 +788,7 @@ pub const DEFAULT_MAX_BLOCKING_THREADS: usize = 512; /// Measured 2026-08-07 on an 8-core box: 600 clients offered seated **477** at 512 and **535** at /// 4096. /// -/// Default 512 preserves the former pool size. A malformed or zero value falls back to this finite +/// The default is 512. A malformed or zero value falls back to this finite /// default. Zero blocking threads would leave the Gateway unable to serve accepted sockets. pub fn max_blocking_threads() -> usize { max_blocking_threads_from_env( @@ -1118,7 +1097,7 @@ mod shard_map_tests { single.check_instance_hosting(|_| None, up), InstanceHosting::Consistent, "a database with no game_config row reads as HOSTING (the module's own unwrap_or(true)) \ - — an unreadable config must never fabricate a fatal" + — an unreadable config must never fabricate a fatal" ); } @@ -1152,7 +1131,7 @@ mod shard_map_tests { up ), InstanceHosting::Consistent, - "the CORRECT Phase A configuration must be completely silent — an always-firing \ + "the CORRECT sharded configuration must be completely silent — an always-firing \ warning gets filtered, which is exactly how the empty-dungeon failure went unnoticed" ); } @@ -1198,7 +1177,7 @@ mod shard_map_tests { } /// An instances shard the map names but that failed to connect degrades to the default database - /// (`resolve_connected`'s documented rule) — and on a Phase A world shard the default database + /// (`resolve_connected`'s documented rule) — and on a non-hosting World Shard the default database /// is exactly the one that hosts nothing, so dungeons WOULD be empty. Reported, and deliberately /// **not fatal**. /// @@ -1244,12 +1223,6 @@ mod shard_map_tests { ); } - /// **The operator's live three-database stack, exactly as deployed** (`lyracore` with the - /// flag correctly off, map 36 routed to `lyracore-instances`, auth on `realm-core`). It must be - /// `Consistent` — completely silent. A false fatal on the one configuration that actually works - /// would be worse than the bug this PR fixes, and realm-core is the wrinkle worth pinning: it is - /// in `databases()` (so it gets a connection and a flag) but it is NOT in `rules`, so it must - /// never be resolved as a dungeon owner and its own flag must never matter. #[test] fn the_operators_live_three_database_stack_is_silent() { let live = ShardMap::parse("lyracore", "36:*=lyracore-instances") @@ -1265,7 +1238,7 @@ mod shard_map_tests { up ), InstanceHosting::Consistent, - "the deployed Phase A stack is correct and must produce NO output at all" + "the deployed sharded Realm is correct and must produce NO output at all" ); // realm-core's flag is irrelevant either way — it owns no map, so flipping it changes nothing. assert_eq!( @@ -1283,9 +1256,7 @@ mod shard_map_tests { ); } - /// The severity split, pinned on its own: exactly ONE verdict stops the gateway, and it is the - /// one nothing but an operator edit can fix. Swapping any two is a one-line mutation in `enforce` - /// that every other test in this file survives. + /// Only a configuration inconsistency that requires an operator edit prevents startup. #[test] fn only_the_unrecoverable_verdict_refuses_to_start() { assert!(InstanceHosting::Consistent.enforce().is_ok()); @@ -1319,11 +1290,6 @@ mod shard_map_tests { #[test] fn an_unreachable_shard_degrades_to_the_default_database_not_to_the_asker() { - // Adversarial review: a shard that is named by the map but failed to connect must - // route to the DEFAULT database. Degrading to "whatever handle asked" is invisible from the - // default handle but wrong from any other — a session already pinned to `pool-a` that ports - // to a map owned by the down `pool-b` would keep its `pool-a` pin and be served by a - // database that owns neither its old location nor its new one. let m = ShardMap::parse("world", "1:*=pool-a, 2:*=pool-b"); let up = |db: &str| db != "pool-b"; // pool-b is down assert_eq!( @@ -1475,13 +1441,6 @@ mod shard_map_tests { // Instance 0 is the OPEN WORLD, not an instance: a character standing on instances-0 in // the open world routes by the map, or they would be pinned to the instance shard forever. assert_eq!(m.instance_owner(0, 0, "instances-0", up), "world"); - // Adversarial review: the assertion above SURVIVES deleting the `instance_id != 0` guard, - // because `instances-0` is not in map 0's pool anyway — that mutation stayed green. The - // sharp form is a map whose bucket 0 and bucket 1 belong to different databases: the open - // world is bucket 0, so BOTH are in that map's pool. A character standing in the open world - // on `world-b` must still be routed by the map to `world-a`; without the guard they answer - // `world-b` and are pinned to whichever database last held them, permanently, with no - // instance anywhere in the picture. let split = ShardMap::parse("world", "0:0=world-a, 0:*=world-b"); assert_eq!(split.instance_pool(0), vec!["world-a", "world-b"]); assert_eq!(split.resolve(0, 0), "world-a"); diff --git a/gateway/src/fd_limit.rs b/gateway/src/fd_limit.rs index 6920315c4..e5ea595fe 100644 --- a/gateway/src/fd_limit.rs +++ b/gateway/src/fd_limit.rs @@ -1,35 +1,3 @@ -//! Raise this process's `RLIMIT_NOFILE` soft limit to its hard limit at startup — the file-descriptor -//! ceiling was root cause when the gateway died outright under a mass-session login storm (see the -//! measured consequence below). -//! -//! # Why a server does this to itself -//! -//! A process may raise its own soft limit up to the hard limit with no privileges at all -//! (`setrlimit(2)`: "an unprivileged process may set only its soft limit... to a value in the range -//! from 0 up to the hard limit"). The soft limit is a *courtesy default* meant to catch runaway -//! programs, not a security boundary — which is why servers routinely lift it and why leaving it -//! alone is the unusual choice, not the safe one. -//! -//! The measured consequence of not doing it (2026-08-07): a default Docker container has -//! `RLIMIT_NOFILE` soft **1024** against a hard limit of **524288**, and the gateway died at ~200 -//! sessions with `Error: Too many open files (os error 24)` — a 512× headroom sitting unused. A live -//! session now needs three client descriptors: the reader, writer and ownership-loss shutdown -//! handle. Each subscribed Shard also needs a SpacetimeDB websocket. Capacity therefore depends -//! on the topology and on how widely the Characters are spread. -//! -//! # Failure is never fatal -//! -//! Both syscalls are best-effort. If either fails we log and continue: a gateway running with a low -//! limit still serves the players it can fit, whereas one that refuses to start serves nobody. That -//! is also why this runs *before* the tokio runtime is built — the limit should be in place before -//! anything opens a descriptor, and it needs no runtime to do it. -//! -//! This is a mitigation for the fd *ceiling*, not for a leak. The per-account `PlayerConn` leak -//! (`release_player_conn` was dead code, so the cached connection was never released at session -//! teardown) is a separate defect: raising the limit buys headroom, it does not stop the accumulation. -//! A further change — the accept loop surviving `EMFILE` — is what keeps the realm alive when the -//! headroom does run out. - use std::io; /// What [`plan_raise`] decided to do about the limits it read. Split out from the syscalls so the diff --git a/gateway/src/load_sample.rs b/gateway/src/load_sample.rs index be65b06e3..148ebf065 100644 --- a/gateway/src/load_sample.rs +++ b/gateway/src/load_sample.rs @@ -1,28 +1,3 @@ -//! Periodic per-shard load sampling — the mechanism that lets seam decisions be driven by queryable -//! load data instead of guesswork. -//! -//! The gateway is the one component that can see the whole realm: every shard's own SpacetimeDB -//! node exposes `/v1/metrics` (the same endpoint this project's internal capacity-benchmark harness -//! reads for its writer-occupancy number), and every shard's coordinator connection already caches -//! its live player-session count. This module samples both on a timer and writes them to -//! realm-core via `record_shard_load` (`module/src/load.rs`), so "which shard is hot" is -//! answerable with `spacetime sql`. -//! -//! # Split: impure I/O vs. the testable driver -//! -//! [`OccupancySampler`] does the one genuinely impure thing here (an HTTP GET against a live node) -//! and is therefore NOT exercised by this crate's tests — same "KNOWN-UNCOVERED, name it" convention -//! already used elsewhere for `Coordinator::escrow_row`. Its Prometheus text -//! PARSING and its occupancy-percentage MATH are pure and pinned directly -//! (the wire harness's `src/bin/bench/metrics.rs` computes the identical thing; this is a -//! from-scratch reimplementation — see that file's own doc for why sharing it as a library was not -//! the fit: `lyracore-shared` cannot take an HTTP-capable dependency, it also compiles to wasm). -//! -//! [`sample_and_record`], the actual per-cycle DRIVER, takes the already-scraped occupancy numbers -//! as a plain `HashMap` and is generic over [`crate::realm_core::RealmDb`] — the exact same seam -//! `realm_core.rs` built for the realm-core split — so it runs under `fake::Handle` in this crate's -//! own tests, mutation-checked, with no live node at all. - use std::collections::{BTreeMap, HashMap}; use std::io::{Read, Write}; use std::net::TcpStream; @@ -50,7 +25,7 @@ pub(crate) fn sample_interval() -> Duration { } /// Deterministic 64-bit FNV-1a hash of this gateway's own identity string (`GatewayConfig::gateway_id`, -/// `LYRACORE_GATEWAY_ID`) — issue #308. `game_shard_load.gateway_key` is `u64`, not `String`: a +/// `LYRACORE_GATEWAY_ID`). `game_shard_load.gateway_key` is `u64`, not `String`: a /// `String` END-appended to an existing table cannot carry a `#[default(...)]` /// (`docs/danger-zones.md` §1.2, "cannot default a `String` column"), so the raw identity never /// crosses the wire into the table — this hash does instead. FNV-1a (not `DefaultHasher`) because @@ -190,16 +165,6 @@ impl Snapshot { /// Occupancy percentage from a CPU-time delta over a wall-clock delta (the same computation the /// internal capacity-benchmark tool uses). Pure, so it is the one piece of this /// module pinned directly against concrete numbers. -/// -/// Clamped to `[0, 100]`... deliberately NOT clamped to 100 — a brief scheduling skew can make a -/// window read slightly over 100%, and clamping that away would hide a real saturation signal -/// behind a false ceiling. Only the FLOOR is enforced: a negative delta (the node restarted or the -/// module republished mid-window, so the counter went backwards) reads as `0.0`, not as a negative -/// occupancy that would make no sense on a graph. This module does not attempt the capacity -/// benchmark's stronger counter-reset detection (`Snapshot::first_negative`, which VOIDS a whole -/// measured rung) — that is the right rigor for a benchmark used to grade a perf claim; this is a -/// background convenience gauge, and reading one 30s sample as "briefly idle" instead of flagging a -/// reset is an acceptable trade for not needing a scheduled reaper or a voided-sample state machine. pub(crate) fn occupancy_pct(cpu_sec_delta: f64, wall_dt_secs: f64) -> f32 { if wall_dt_secs <= 0.0 || !cpu_sec_delta.is_finite() { return 0.0; @@ -290,17 +255,6 @@ impl OccupancySampler { // The driver — pure given the RealmDb abstraction, tested against `realm_core::fake::Handle`. // =================================================================================================== -/// Record measured occupancy and cached Session counts for the connected World Shards, -/// Instance Pools and Realm-core. A single-Shard Realm is sampled once. Returns one "SHARDLOAD ..." -/// line per shard for the caller to log at the sample cadence (the QUEUESTAT/AOISTAT convention, -/// `gateway/src/world/mod.rs`) — visible without `spacetime sql`. -/// -/// `occupancy_by_shard` is whatever [`OccupancySampler::sample_all`] returned THIS cycle — passed -/// in rather than sampled here so this function stays generic over [`RealmDb`] and therefore -/// testable without a live node or a live HTTP scrape. -/// -/// `this_gateway_key` is [`gateway_key`] of this process's own `LYRACORE_GATEWAY_ID` — threaded in -/// (rather than read from env here) for the same testability reason `occupancy_by_shard` is. pub(crate) fn sample_and_record( db: &D, occupancy_by_shard: &HashMap, @@ -451,10 +405,6 @@ spacetime_txn_cpu_time_sec_sum{db="zzz999",txn_type="Reducer"} 99.0 assert!(parse_db_ids(" \n # just a comment\n").is_empty()); } - // ------------------------------------------------------------------------------------- - // The driver, wired against the realm_core fake — no live node, mutation-checked. - // ------------------------------------------------------------------------------------- - use crate::realm_core::fake::{realm, Handle}; const WORLD: &str = "world"; diff --git a/gateway/src/logon/mod.rs b/gateway/src/logon/mod.rs index d8d65b10a..00a6d4cd7 100644 --- a/gateway/src/logon/mod.rs +++ b/gateway/src/logon/mod.rs @@ -463,11 +463,7 @@ pub async fn run(cfg: GatewayConfig, coordinator: Coordinator) -> Result<()> { /// re-resolved from the username by [`CoordinatorStore::world_account_id`]. The id this store is /// handed came from realm-core and is meaningless anywhere else. /// -/// Generic over the STORE rather than hard-wired to `Coordinator`, because `Coordinator` wraps a -/// live SpacetimeDB websocket and nothing in the crate could execute these bodies without a node — -/// which is why four mutations against them, including "read the SRP6 material off the world DB", -/// survived the realm-core PR's own suite before this fix. Production binds `D = Coordinator`; -/// `realm_core::tests` binds `D = fake::Handle` and runs THESE bodies, not a model of them. +/// Production binds `Coordinator`; the RealmDb Fake exercises the same Store in unit tests. pub(crate) struct CoordinatorStore { coordinator: D, } @@ -958,9 +954,6 @@ mod tests { other => panic!("expected realm list, got {other:?}"), } - // EVERY store call that then talks to the world shard must be told the username. Review - // caught `bound_identity` and `realms` still running on realm-core's id after - // `save_session` was fixed, so this asserts the whole set, not the one method. for method in ["bound_identity", "save_session", "realms"] { assert_eq!( store.told(method).as_deref(), diff --git a/gateway/src/main.rs b/gateway/src/main.rs index e35afc58d..bb1450fa9 100644 --- a/gateway/src/main.rs +++ b/gateway/src/main.rs @@ -31,8 +31,6 @@ use anyhow::Result; use config::GatewayConfig; use provision_cli::{parse_gateway_mode, read_password_line, GatewayMode}; -// Heap profiling: `--features dhat-heap` swaps in dhat's allocator and -// writes dhat-heap.json at exit. Never for production — it records every allocation and is slow. #[cfg(feature = "dhat-heap")] #[global_allocator] static ALLOC: dhat::Alloc = dhat::Alloc; @@ -198,18 +196,7 @@ async fn provision(cfg: &GatewayConfig, username: &str, password: &[u8]) -> Resu // character is owned by the world shard's account row. coordinator.provision_account(&user, &salt, &pw_verifier)?; - // REALM-CORE's copy, which is the one that can actually log you in. The SRP6 challenge is - // answered from realm-core and never from a world shard's cache — pinned by - // `the_srp6_challenge_material_comes_from_realm_core_never_the_world_shards_cache` - // (`realm_core.rs`). Without this write an account provisioned on a SHARDED realm exists, - // reports success, and can never log in: the logon server looks it up on realm-core and finds - // nothing. It cost a whole capacity-benchmark run (200 accounts, every login refused) before - // anyone read the account tables on both databases. - // - // Unsharded, `realm_core()` IS this handle and the second call would be a duplicate, so it is - // skipped by name. `?` rather than best-effort: a provision that half-succeeded is exactly the - // state this comment exists to describe, and reporting success for it is what made the failure - // so hard to see. + // SRP6 credentials belong to Realm-core. Unsharded, it is the world handle already written. let rc = coordinator.realm_core()?; if rc.shard_name() != coordinator.shard_name() { rc.provision_account(&user, &salt, &pw_verifier)?; diff --git a/gateway/src/realm_core.rs b/gateway/src/realm_core.rs index 8787358c5..d0535495b 100644 --- a/gateway/src/realm_core.rs +++ b/gateway/src/realm_core.rs @@ -1,30 +1,6 @@ -//! The realm-core split, written against a trait instead of a live connection. -//! -//! # Why this file exists -//! -//! An adversarial review ran 16 mutations against the realm-core auth code. Eight went -//! red — every one of them in `config.rs`, the pure resolver layer. Eight SURVIVED, and every one -//! of those lived in `CoordinatorStore` or `impl WorldStore for Coordinator`: the layer that -//! actually performs the split. Among the survivors were *"`account()` reads SRP6 material off the -//! world DB"* and *"`lookup_session` reads the session key K off the world DB's cache"* — the exact -//! regression the realm-core split exists to prevent, invisible to a fully green suite. The reason was -//! mechanical, not cultural: `Coordinator` wraps a live SpacetimeDB websocket, so nothing in the -//! crate could execute those bodies without a node. -//! -//! # The seam -//! -//! The same one the transfer transport uses, and for the same reason: make the -//! PRODUCTION function generic over the store type, and put the store behind a small trait. So -//! [`RealmDb`] is `Coordinator` reduced to the calls the realm-core split actually makes; -//! `Coordinator` implements it by forwarding to its own inherent methods (Rust resolves inherent -//! methods first, so those forwards are views, not recursion), and [`fake::Handle`] implements it -//! over an in-memory two-database topology. Every function below is then run BY THE TESTS, not -//! modelled by them — a harness that re-implemented the rules would reproduce the exact failure -//! this file is about. -//! -//! What is NOT modelled here is the transport: `Coordinator`'s own one-line bodies (the websocket -//! read, the `call_reducer!`) are substituted wholesale by the fake. A headless test cannot drive -//! the real connection. +//! Realm-core routing through the `RealmDb` seam. +//! Production uses Coordinators; tests use a Fake with multiple databases. +//! Credentials and session keys come from Realm-core, while Character state stays on World Shards. use anyhow::{anyhow, Result}; @@ -170,7 +146,7 @@ pub(crate) trait RealmDb: Clone + Sized + Send + Sync { /// per-shard population (a point-in-time snapshot, not a windowed average). fn session_count(&self) -> usize; /// Record `shard`'s occupancy + session sample, tagged with `gateway_key` (this gateway - /// process's identity, hashed — `load_sample::gateway_key`, issue #308) so N gateway processes + /// process's identity, hashed, `load_sample::gateway_key`, so N gateway processes /// sampling the same shard keep independent ring history instead of clobbering each other. fn record_shard_load( &self, @@ -324,29 +300,10 @@ pub(crate) fn settle_shard_index(db: &D, character_guid: u64) -> Opt Some(resolved) } -/// Publish a settled transfer's destination into the REALM-CORE character→shard index. -/// -/// # Why this is not "a best-effort write the gateway might make" -/// -/// The design calls for an index updated *transactionally by the escrow's finish step*. -/// `transfer::do_finish` does exactly that — but only for the database it runs on, because there is -/// no transaction spanning two SpacetimeDB databases. Realm-core's copy is the one `home_shard` -/// actually reads, and before this function existed nothing ever wrote it: `set_character_shard`'s -/// only caller was the login self-heal, so the directory learned about a completed transfer at the -/// character's NEXT login, from a probe, and the "transactional" write landed on world-shard copies -/// nothing reads. -/// -/// What is achievable across two databases is REPLICATION of a transactionally-written fact, and -/// that is what this is. `world::transfer::run_transfer` calls it as step 5b — a required step of -/// the drive, strictly after `finish_transfer` returned `Ok`, deriving `(map, instance)` from the -/// same escrow out-row fields `do_finish` recorded its own receipt from. So it can never name a -/// destination for a transfer that did not settle, which is the property "not a separate call that -/// can commit independently" is protecting: a stale-index generator writes the index for transfers -/// that never happened, and this cannot. -/// -/// Destination recovery retains Realm's observed predecessor and the arrival fence's exact crossing -/// identity, then supplies both to the same compare-and-set. A delayed recovery cannot settle a -/// later crossing even when it returns to the same partition. +/// Replicate a settled destination into the Realm-core Character locator after source completion. +/// The source receipt is transactional on its own database; Realm-core replication follows it. +/// Recovery includes the observed predecessor and crossing identity in the compare-and-set +/// so a delayed recovery cannot settle a later crossing to the same partition. pub(crate) fn publish_shard_index( db: &D, character_guid: u64, @@ -640,31 +597,9 @@ fn wait_for_settled_locator( // `settle_home_shard`'s holder lookup — the index is used, not just written // =============================================================================================== -/// Where `character_guid` CURRENTLY lives, consulting the realm-core character→shard index FIRST -/// and paying the full scan only on a miss. This is `settle_home_shard`'s holder-resolution step — -/// the method `world::route_home` actually calls on every world entry — replacing the unconditional -/// scan (`Coordinator::locate_character`) it used before. -/// -/// The realm-core work wrote the index and gave it a self-heal (`settle_shard_index`, above), but that sits -/// behind `WorldStore::home_shard`, which `settle_home_shard` overrides — so in production the index -/// sat next to the login path unread and the self-heal never ran. This -/// function is the fix, written against the same [`RealmDb`] seam for the same reason: so it runs -/// UNDER THE TESTS via [`fake::Handle`] rather than being merely described by a source scan of -/// `Coordinator`'s live-node-only override. -/// -/// A hint is trusted only far enough to know WHICH ONE shard to ask — that shard's own row (or -/// escrow) is still the truth, so a wrong or stale hint costs one probe, never a wrong route, and -/// the mismatch is healed back into the index (`heal_shard_entry`, the same rule -/// `settle_shard_index` uses). The miss path — no hint, an unreachable realm-core, or the hinted -/// shard no longer holding the character — scans every connected shard, default first, exactly as -/// the older `locate_character` did: a shard holding an in-flight ESCROW wins outright (between -/// `import_character_blob` and `finish_transfer` BOTH databases can hold a durable row for the guid, -/// and the escrow-holder is the only side a resumed transfer can be driven from — see -/// `world::transfer`), otherwise the first shard whose row answers. In practice the index already -/// names the escrow-holding shard by the time a transfer starts (nothing repoints it to a -/// destination before the transfer settles — that is `publish_shard_index`'s whole contract), so the -/// fast path IS the escrow case too; the scan is what a never-yet-healed or genuinely stale index -/// falls back into. +/// Resolve a Character holder from the Realm-core index, verifying each hint against its Shard. +/// A missing or stale hint falls back to a census. An escrow holder takes precedence over +/// a destination copy so a resumed transfer can complete from its source. pub(crate) fn locate_home_shard(db: &D, guid: u64) -> Option { // This function's own copy of the short-circuit. `settle_home_shard` (`stdb::world_store`) already // guards its call to this function with an identical `if !self.is_sharded() { return Ok(None); @@ -701,9 +636,7 @@ pub(crate) fn locate_home_shard(db: &D, guid: u64) -> Option { // Miss: the full scan. A shard holding an escrow wins outright; otherwise prefer a shard whose // row AGREES with the shard map, and only then fall back to first-responder order. // - // THE DISAMBIGUATOR. This used to take the first shard that answered at all, - // so a guid with rows on two shards resolved by ITERATION ORDER — default first — and a stale - // artefact on the default shard beat the character's real home. Reproduced by a reviewer. + // // The rule, stated so it can be argued with: **a durable row is authoritative only on the shard // the shard map assigns its own location to.** A row on `core` saying "I am at map 36" describes @@ -829,12 +762,8 @@ pub(crate) fn resolve_delete_shard(db: &D, guid: u64) -> Result super::fake::Handle { let h = realm(&[WORLD, CORE], "", Some(CORE)); // realm-core: the authority. id 9, salt 0xAA. @@ -1545,10 +1461,6 @@ mod tests { assert!(h.db_at(WORLD).gm_commands.lock().unwrap().is_empty()); } - // ------------------------------------------------------------------------------------- - // M1 — `account()` reads SRP6 material off the world DB, not realm-core - // ------------------------------------------------------------------------------------- - #[test] fn the_srp6_challenge_material_comes_from_realm_core_never_the_world_shards_cache() { let h = split_realm(); @@ -1632,11 +1544,6 @@ mod tests { ); } - // ------------------------------------------------------------------------------------- - // M2 — `save_session` carries realm-core's id to the world shard - // M3 — the world-shard write-through cache write is skipped entirely - // ------------------------------------------------------------------------------------- - #[test] fn save_session_writes_each_database_under_that_databases_own_account_id() { let h = split_realm(); @@ -1647,22 +1554,22 @@ mod tests { assert_eq!( h.db_at(CORE) - .sessions - .lock() - .unwrap() - .get(&9) - .map(|(k, _, _)| *k), +.sessions +.lock() +.unwrap() +.get(&9) +.map(|(k, _, _)| *k), Some(K), "realm-core's `game_session` must be written under REALM-CORE's id (9) — it is the row \ every world gateway later reads to complete a handshake" ); assert_eq!( h.db_at(WORLD) - .sessions - .lock() - .unwrap() - .get(&3) - .map(|(k, _, _)| *k), +.sessions +.lock() +.unwrap() +.get(&3) +.map(|(k, _, _)| *k), Some(K), "the world shard's write-through cache write is missing, or landed under the wrong id. \ Its real job is binding `game_character.owner_identity` for this account's characters \ @@ -1703,10 +1610,6 @@ mod tests { ); } - // ------------------------------------------------------------------------------------- - // M4 — `bound_identity` / `realms` pass realm-core's id to the world coordinator - // ------------------------------------------------------------------------------------- - #[test] fn every_world_side_call_resolves_the_world_shards_own_account_id_from_the_username() { let h = split_realm(); @@ -1767,11 +1670,6 @@ mod tests { ); } - // ------------------------------------------------------------------------------------- - // M5 — `lookup_session` reads K off the world DB's cache - // M6 — `lookup_session` returns realm-core's id as `WorldSession::account_id` - // ------------------------------------------------------------------------------------- - #[test] fn the_world_handshake_reads_k_from_realm_core_and_the_account_id_from_the_world_shard() { let h = split_realm(); @@ -1796,9 +1694,7 @@ mod tests { "the world handshake completed against the WORLD shard's cached copy of K. That copy \ is refreshed at logon and never authoritative: sourcing K from it means a \ gateway restarted mid-session, or a client reconnecting to a different shard's \ - gateway, authenticates off a snapshot instead of realm state. This is the precise \ - regression the realm-wide session row exists to prevent, and it survived every test \ - before this one." + gateway, authenticates off a snapshot instead of realm state. Realm-core owns the current session key." ); assert_eq!( s.account_id, 3, @@ -1895,12 +1791,6 @@ mod tests { ); } - // ------------------------------------------------------------------------------------- - // M7 — the self-heal index write-back is removed - // M8 — the `!is_sharded()` short-circuit is removed - // ------------------------------------------------------------------------------------- - - /// Two world shards + realm-core, with the character resident on `instances`. fn routed_realm() -> super::fake::Handle { let h = realm(&[WORLD, INSTANCES, CORE], "36:*=instances", Some(CORE)); h.db_at(INSTANCES) @@ -1933,7 +1823,7 @@ mod tests { Some((36, 7)), "the stale entry was not healed. Without the write-back every login pays the full \ shard probe forever, and the index — the thing world entry and instance entry route on \ - — never becomes \ + — never becomes \ true. This is the fallback that covers a gateway killed between `finish_transfer` and \ `publish_shard_index`, so it must keep working." ); @@ -1994,29 +1884,11 @@ mod tests { // `locate_home_shard`: `settle_home_shard`'s holder lookup, index-first // ------------------------------------------------------------------------------------- - /// **The stranded-copy sequence, whole.** A crash between `import_character_blob` and - /// `finish_transfer` left the player settled on the destination and a durable copy stranded on - /// the source, escrow still held, with nothing left that would ever re-drive it. Live evidence: - /// - /// ```text - /// outbound: settle 13: holder=lyracore owner=lyracore-instances escrow=false (36/42) - /// recovery: settle 13: holder=lyracore-instances owner=lyracore-instances escrow=false (36/42) - /// ``` - /// - /// The recovery login resolved the DESTINATION as the holder, so the source's escrow was never - /// read (`settle_home_shard` reads it from the holder alone), owner came out equal to holder, - /// and `settle_transfer` returned through its no-op branch — no `run_transfer` line in the log - /// at all. - /// - /// Both phases run through the PUBLIC function, in order, because the defect is the handoff - /// between them: phase 1's write is what makes phase 2 answer wrongly. Testing either alone - /// passes — phase 1 "heals the index" and phase 2 "trusts the index", both as designed. + /// An in-flight source Escrow remains the recovery authority after destination import. #[test] fn a_mid_portal_heal_never_makes_the_next_login_resolve_the_destination_as_the_holder() { let h = realm(&[WORLD, INSTANCES, CORE], "36:*=instances", Some(CORE)); - // PHASE 1 — the outbound login. She is on WORLD, and her location already carries the - // portal's destination (`teleport_player` stamps `pending_instance_id` before the transfer - // is driven), so `character_location` on WORLD answers the map INSTANCES owns. + h.db_at(WORLD) .characters .lock() @@ -2038,8 +1910,6 @@ mod tests { _index` after a successful `finish_transfer` is the only thing allowed to move it." ); - // PHASE 2 — the crash landed between import and finish, then the player logs back in. - // WORLD holds the frozen source copy and the escrow; INSTANCES holds the imported copy. h.db_at(WORLD).escrows.lock().unwrap().insert(100); h.db_at(INSTANCES) .characters @@ -2056,15 +1926,6 @@ mod tests { ); } - /// **The scan must not resolve by iteration order.** - /// - /// Reproduced by a reviewer: a guid with a stale row on the DEFAULT shard and its real - /// row elsewhere, with no index entry, resolved to the default shard because the scan took the - /// first shard that answered at all and `shards` is default-first. - /// - /// The disambiguator is "a durable row is authoritative only on the shard the shard map assigns - /// its own location to". Mutation targets: drop the `resolve(...) == shard_name()` comparison, or - /// collapse `agreeing.or(disagreeing)` to `disagreeing.or(agreeing)`, and this goes red. #[test] fn the_scan_prefers_the_shard_whose_row_agrees_with_the_map_over_iteration_order() { let h = realm(&[WORLD, INSTANCES, CORE], "36:*=instances", Some(CORE)); @@ -2081,7 +1942,7 @@ mod tests { .lock() .unwrap() .insert(100, (1, (36, 7))); - // No index entry: this is the fallback-scan path, exactly the reviewer's setup. + assert_eq!( locate_home_shard(&h, 100).expect("resolves").shard_name(), INSTANCES, @@ -2091,11 +1952,6 @@ mod tests { ); } - /// …and the preference must never become a FILTER: if the only row anywhere disagrees with the - /// map, it is still returned. Routing to a stale-looking row is recoverable and is what happens - /// today; routing to nothing is a failed login. - /// - /// Mutation target: change `agreeing.or(disagreeing)` to plain `agreeing` and this goes red. #[test] fn a_lone_disagreeing_row_still_resolves_rather_than_failing_the_login() { let h = realm(&[WORLD, INSTANCES, CORE], "36:*=instances", Some(CORE)); @@ -2117,8 +1973,6 @@ mod tests { /// The SAME rule with the roles swapped, and it exists because the test above does not pin the /// rule it claims to. /// - /// An adversarial review found a wrong guard that passes all 496 tests: - /// /// ```ignore /// if shard.shard_name() == shard.shard_map().default_db() { return; } // "never heal on the default shard" /// ``` @@ -2137,7 +1991,7 @@ mod tests { fn a_heal_is_declined_by_the_shard_map_not_by_being_the_default_database() { let h = realm(&[WORLD, INSTANCES, CORE], "36:*=instances", Some(CORE)); // She is durable on INSTANCES, but her location is open-world map 0 — which the shard map - // assigns to WORLD. Mid-portal home, in other words: the mirror image of phase 1 above. + // assigns to WORLD. Healing must follow the durable location. h.db_at(INSTANCES) .characters .lock() @@ -2260,9 +2114,7 @@ mod tests { ); } - /// Sibling of `locate_character_still_prefers_the_shard_holding_the_escrow` (the older scan's - /// own tripwire): `locate_home_shard`'s FALLBACK scan must keep the same priority, or a resumed - /// transfer gets driven from the wrong side of a fenced-import window. + /// Fallback census prefers the Escrow holder over an imported destination copy. #[test] fn locate_home_shard_still_prefers_the_shard_holding_the_escrow_in_the_fallback_scan() { let h = realm(&[WORLD, INSTANCES, CORE], "36:*=instances", Some(CORE)); @@ -2289,14 +2141,6 @@ mod tests { ); } - /// With `LYRACORE_REALM_CORE` (and `LYRACORE_SHARD_MAP`) unset, the resolver must be - /// byte-identical to the pre-sharding gateway — no realm-core read, no shard scan, no heal - /// write. `settle_home_shard`'s OWN `if !self.is_sharded() { return Ok(None); }` (`stdb:: - /// world_store.rs`) already guards this in production, unchanged, but it lives on `Coordinator` - /// and cannot be exercised by any test without a live SpacetimeDB connection — an adversarial - /// review confirmed inverting it left all 468 + 57 gateway tests green, caught only by a - /// source-scan substring check that still matches the inverted line. This function's OWN copy - /// of the same short-circuit is what a test can actually reach, so it is what is pinned here. #[test] fn locate_home_shard_short_circuits_on_an_unsharded_gateway_reading_nothing() { let h = realm(&[WORLD], "", None); @@ -2334,7 +2178,7 @@ mod tests { owner.shard_name(), INSTANCES, "delete must run on the shard actually holding the row, not the default `world` shard \ - — routing it there is exactly the NOT_FOUND-shaped failure players hit" + — routing it there is exactly the NOT_FOUND-shaped failure players hit" ); } diff --git a/gateway/src/stdb/aoi.rs b/gateway/src/stdb/aoi.rs index 8d0ce1474..089d3245f 100644 --- a/gateway/src/stdb/aoi.rs +++ b/gateway/src/stdb/aoi.rs @@ -1,8 +1,7 @@ //! What is left of the Area-of-Interest tier after the shared-connection model landed: the //! per-viewer state that outlived the per-player subscription, and the frozen SQL oracle its //! replacement is tested against. -//! -//! # What used to be here + //! //! Before the shared-connection model, this file owned a per-player, grid-SCOPED subscription //! over four tables that recentered (unsubscribe + resubscribe) as the player crossed cells. All @@ -55,20 +54,12 @@ impl ViewerGates { /// The FROZEN subscription-SQL oracle, from before the shared-connection model. /// -/// These builders are byte-for-byte the ones that generated the per-player AOI box subscription this -/// file used to send. They are compiled only under `cfg(test)` and nothing in the gateway calls them -/// any more — they exist so -/// `world_index::tests::the_index_visible_set_equals_the_box_subscription_sql_for_every_viewer` can -/// keep asserting that the in-process cell index selects EXACTLY the rows the database used to -/// select, forever, and not merely at the moment of the cutover. -/// /// **Do not "fix" these to match the index.** Their whole value is being the independent side of /// that comparison; a change here that makes a failing differential test pass has deleted the test. #[cfg(test)] mod frozen_sql_oracle { use lyracore_shared::spatial::GridBox; - /// The four tables a player's AOI box used to scope. pub(crate) const BOX_SCOPED_TABLES: [&str; 4] = [ "game_world_entity", "game_gameobject", diff --git a/gateway/src/stdb/armor.rs b/gateway/src/stdb/armor.rs index 47c408d37..545984d07 100644 --- a/gateway/src/stdb/armor.rs +++ b/gateway/src/stdb/armor.rs @@ -60,7 +60,7 @@ fn gear_armor_contribution(slot: u8, stat_armor: i32, max_durability: u32, durab } /// Everything the paperdoll shows that GEAR/AURAS move on the STR/AGI/STA/INT/SPI/AP/damage-range/crit -/// half of the sheet (#517 + #532) — a plain row read of `module::spell::recompute_sheet`'s output, +/// half of the sheet, a plain row read of `module::spell::recompute_sheet`'s output, /// never a second fold. `strength`/`agility`/`stamina`/`intellect`/`spirit` are the BASE attributes (the /// white `UNIT_FIELD_STAT0..4` total); `*_bonus` is the SIGNED aura+gear(+enchant) delta the caller /// splits into the green/red `PLAYER_FIELD_POSSTAT`/`NEGSTAT` pair via `.max(0)`/`.min(0)` — sign @@ -95,17 +95,13 @@ pub(crate) fn sheet_stats(db: &RemoteTables, guid: u64) -> Option u32 { db.game_aura() .iter() @@ -311,7 +307,7 @@ mod tests { } // The former `melee_attack_power_for`/`swing_range_ap` gateway-side mirrors (and the parity tests - // that pinned them against `lyracore_module`) are GONE (#517): `sheet_stats` above is now a plain + // that pinned them against `lyracore_module`) are GONE : `sheet_stats` above is now a plain // read of `module::spell::recompute_sheet`'s output, so there is no gateway-side formula left to // drift from the module — the module row IS the source of truth. @@ -320,7 +316,7 @@ mod tests { // `effective_armor` itself needs a live `RemoteTables` subscription cache and is NOT // unit-tested here (no ReducerContext mocking) — this only pins that its two REAL pure // terms (`aura_armor_contribution`, `gear_armor_contribution`) compose by simple addition - // with a base, and that the max(0, ..) clamp `effective_armor` applies to that same sum + // with a base, and that the max(0,..) clamp `effective_armor` applies to that same sum // never underflows on a debuff exceeding base + gear. let base = 40i64; // agility 20 * 2 let aura = aura_armor_contribution(0xA1, 0x01, 160, 1) as i64; // Demon Skin diff --git a/gateway/src/stdb/connection.rs b/gateway/src/stdb/connection.rs index 67507e4de..2ee265bde 100644 --- a/gateway/src/stdb/connection.rs +++ b/gateway/src/stdb/connection.rs @@ -472,8 +472,6 @@ struct ReducerCompletionState { HashMap>>, } -/// The SDK callback keeps module refusals distinct from failures in the SDK itself. Both used to -/// share a `String`, which made callers infer provenance from rendered error text. #[derive(Debug, Clone)] pub(crate) enum ReducerCompletionFailure { Rejected(String), @@ -1058,7 +1056,7 @@ fn coordinator_queries(sharded_tables: bool) -> Vec<&'static str> { // cheapest possible query, but that was never the cost. The cost was that ~600 DISTINCT // query strings can neither be shared nor pruned, so every committed transaction woke ~600 // SDK pumps to re-evaluate them: load average 11–14 on 8 cores at 34% CPU with 7 runnable - // threads (clockworklabs/SpacetimeDB#2783). One subscription wakes one pump. + // threads (clockworklabs/SpacetimeDB). One subscription wakes one pump. // // ⚠ These are the two HIGHEST-RATE tables in the module (one upsert per mover per movement // heartbeat, one per creature leg). They are handled by `world_view`'s dispatch, which does @@ -1075,55 +1073,7 @@ fn coordinator_queries(sharded_tables: bool) -> Vec<&'static str> { // RECOVERY: a restarted gateway re-derives an in-flight transfer's id, destination and // payload from this row alone. // - // BASE list, not `sharded_tables`: the table is the escrow primitive itself, present on every - // module since long before any of the sharding work, so subscribing it unconditionally - // cannot brick the restart of a gateway whose module predates multi-database routing. Nothing READS - // it unsharded (`settle_home_shard` short-circuits on `is_sharded()`). - "SELECT * FROM game_transfer_out", - // The destination fence. A world-entry retry reads its exact presence before doing the - // required party synchronization, and the bot driver binds an exact intent identity to it - // before release. Like the source escrow, this is private owner-token state. The table - // predates the multi-Shard query split, so it stays in the base subscription for older - // Modules. - "SELECT * FROM game_transfer_in", - // The source instance lease owns the admitted map and party during Transfer. This table - // predates the Shard split, so the base Coordinator can read that lease. - "SELECT * FROM game_instance", - // ── THE COORDINATOR-RELAY RULE ───────────────────────────────────────────────────────── - // Every relay whose loss leaves the CLIENT stuck in a wrong state — as opposed to merely - // late — is subscribed HERE, on the stable coordinator connection, and never on the - // session-scoped connection. Historical AOI subscriptions churned as the player moved, and - // the SDK's in-flight apply could swallow a concurrent transaction's deltas: an - // instance-creating portal entry lost its teleport event and left the player despawned. - // The owner token bypasses recipient RLS, so every player's rows arrive here. Shared - // callbacks route addressed rows through WorldView's typed owner/identity indexes. - // - // Members of the class include teleport, XP/level-up, quest log, item instances, the addon - // bridge, reputation, explored-area fog, and realm-core's group and chat twins. Every one - // is armed once in `world_view::arm_shard` (or `arm_realm_private`) and re-armed on - // reconnect. - // - // Teleport events — the TRANSFER relay, and the failure that produced the rule. - "SELECT * FROM game_teleport_event", - // XP/level-up events, same rule: they ride large kill transactions and - // their relays moved off the churning per-player conn onto this stable one. - "SELECT * FROM game_xp_event", - "SELECT * FROM game_levelup_event", - // Bot-initiated (serendipity) group invites: the module cannot reach realm-core - // to write a party itself, so it writes the DECISION here and the coordinator's - // `world::party::run_bot_invite` relay executes it — the same guid-based `realm_group_op` a - // player's own CMSG_GROUP_INVITE takes. Rides this stable connection for the same reason the - // XP/levelup/teleport relays do: there is no player connection to ride at all here (a bot has - // none), so the coordinator is the ONLY connection that could ever see this row. - // - // BASE list, not `sharded_tables`: bots run on every topology, single-database included — - // gating this behind sharding would silently stop every serendipity invite on the common - // single-database dev/test deployment (`local-test-harness`), the opposite of the - // `game_config` reasoning below but the same list for the same kind of reason. One-time - // bootstrap cost, same as every other base-list table added after day one: a gateway built - // against this change must not be RESTARTED until the module carrying this table has been - // published, or `connect_blocking`'s subscription fails to apply (`coordinator_queries`'s own - // doc comment). + // Session-less Group Intents run on every topology, including a single database. "SELECT * FROM game_bot_invite_intent", "SELECT * FROM game_party_command_intent WHERE pending = true", "SELECT * FROM game_party_command_dispatch_lane", @@ -1422,8 +1372,8 @@ const COORDINATOR_WATCHDOG_POLL: Duration = Duration::from_secs(3); /// Readers retain the old cache during recovery; failed connections retry on the next poll. fn spawn_coordinator_watchdog(inner: Arc) -> std::thread::JoinHandle<()> { std::thread::Builder::new() - .name("stdb-coordinator-watchdog".into()) - .spawn(move || { +.name("stdb-coordinator-watchdog".into()) +.spawn(move || { let mut previous = inner.motion_batch.delivery.snapshot(); let mut sampled_at = std::time::Instant::now(); loop { @@ -1491,7 +1441,7 @@ fn spawn_coordinator_watchdog(inner: Arc) -> std::thread::Join } } }) - .expect("spawn coordinator watchdog") +.expect("spawn coordinator watchdog") } /// How often the loot-roll relay promotes staging rolls and settles resolved winners. Short @@ -1530,7 +1480,7 @@ fn spawn_load_sampler( ) -> std::thread::JoinHandle<()> { let sampler = crate::load_sample::OccupancySampler::from_env(&stdb_uri); let interval = crate::load_sample::sample_interval(); - // Hashed once at startup (issue #308): `game_shard_load.gateway_key` is what keys this + // Hashed once at startup : `game_shard_load.gateway_key` is what keys this // process's samples apart from every OTHER gateway process's, so N gateways sampling the same // shard sum to a realm-wide total instead of the last writer clobbering the rest. let this_gateway_key = crate::load_sample::gateway_key(&gateway_id); @@ -1821,10 +1771,6 @@ mod coordinator_query_tests { #[test] fn a_single_database_gateway_subscribes_no_multi_database_table() { - // A review caught exactly this class of regression — a table subscribed on every - // coordinator connection even when unconfigured, which bricks a gateway restarted before - // the module was republished. Each sharded-only table re-enters the same trap, so the - // guard gets a NAMED test rather than a comment. let single = coordinator_queries(false); for table in MULTI_DB_TABLES { assert!( @@ -2430,8 +2376,8 @@ macro_rules! call_reducer { >(); let completion = $live.reducer_completion.clone(); let call_id = completion - .register(tx) - .map_err(|e| { +.register(tx) +.map_err(|e| { $crate::stdb::connection::ReducerCallError::fatal(format!( concat!($what, " reducer transport disconnected: {}"), e @@ -2439,9 +2385,9 @@ macro_rules! call_reducer { })?; let callback_completion = completion.clone(); $live - .conn - .reducers - .$method($($arg,)* move |_ctx, status| { +.conn +.reducers +.$method($($arg,)* move |_ctx, status| { callback_completion.finish(call_id, match status { Ok(Ok(())) => Ok(()), Ok(Err(reason)) => Err( @@ -2452,7 +2398,7 @@ macro_rules! call_reducer { ), }); }) - .map_err(|e| { +.map_err(|e| { completion.cancel(call_id); $crate::stdb::connection::ReducerCallError::sdk_send($what, e) })?; @@ -2767,7 +2713,7 @@ impl Coordinator { } })); } - // The cross-shard group and chat twins (#22) ride realm-core's connection, armed only + // The cross-shard group and chat twins ride realm-core's connection, armed only // when realm-core is a DISTINCT database (a world shard's own `arm_shard` above already // watches these tables, and a second registration would deliver every packet twice). if let Ok(realm) = self.realm_core() { @@ -2883,25 +2829,8 @@ impl Coordinator { .collect() } - // Realm-core removed `realm_shard()` — the old "the default shard is where accounts AND - // characters live" assumption. Both of its jobs now have a more precise owner: accounts and sessions belong to `realm_core()` - // below, and "which shard holds this character" is answered by probing (`world_shards()`) rather - // than by assuming the default — the assumption that stops being true the first time a transfer - // actually moves someone. It had no other callers. - - /// The **realm-core** handle: the database that owns accounts, sessions, and the - /// character→shard index. Unconfigured (`LYRACORE_REALM_CORE` unset, blank, or naming the world - /// database) → the default shard, i.e. auth reads and writes land exactly where they land - /// today. Configured but not connected → `Err`, and every auth path must propagate that rather - /// than fall back to the world DB's stale auth cache (see `ShardMap::auth_db`). - /// - /// "Connected" is checked LIVE, not "was in the set at boot". `conns` is built once at startup - /// and never shrinks, so membership alone would mean a realm-core that connected and then died - /// still answers auth — out of the SDK's last-known local cache, which is exactly the stale - /// salt/verifier/`banned` snapshot fail-closed exists to refuse. The watchdog reconnects it - /// within a poll, and until it does, logons are refused rather than served from that snapshot. - /// The unconfigured path never evaluates this predicate at all (`auth_db` short-circuits), so - /// it costs a single-database gateway nothing. + /// Check current subscription health before returning the authentication authority. + /// The configured connection map survives disconnection and may retain stale credentials. pub(crate) fn realm_core(&self) -> Result { let db = self .1 diff --git a/gateway/src/stdb/reads/account.rs b/gateway/src/stdb/reads/account.rs index eb4d6872b..f916155bd 100644 --- a/gateway/src/stdb/reads/account.rs +++ b/gateway/src/stdb/reads/account.rs @@ -13,7 +13,7 @@ use super::super::views::{character_view, AccountRow, RealmRow}; use crate::realm_core::SessionKey; impl Coordinator { - /// Read the single realm row for the realm-list reply (Phase 1). + /// Read the single realm row for the realm-list reply. pub fn realm(&self) -> Result { self.0 .coord() @@ -34,7 +34,7 @@ impl Coordinator { .ok_or_else(|| anyhow!("no game_realm row in the coordinator cache")) } - /// Read an account's SRP6 salt/verifier for the logon challenge (Phase 1). + /// Read an account's SRP6 salt/verifier for the logon challenge. pub fn account_by_username(&self, username: &str) -> Result> { Ok(self .0 @@ -68,7 +68,7 @@ impl Coordinator { Ok(n.min(u8::MAX as usize) as u8) } - /// Read an account's characters for the character-select screen (Phase 3). In production + /// Read an account's characters for the character-select screen. In production /// this reads the per-player `game_character` subscription cache (RLS-restricted to owner). /// Equipment slots (0..=18) are populated from `game_item_instance` + `game_item_template` /// so the client renders the character's gear on the select screen instead of all-naked. diff --git a/gateway/src/stdb/reads/auction.rs b/gateway/src/stdb/reads/auction.rs index 45b3c87a5..273cd75e6 100644 --- a/gateway/src/stdb/reads/auction.rs +++ b/gateway/src/stdb/reads/auction.rs @@ -291,8 +291,6 @@ mod tests { )); } - /// The usable checkbox used to answer from the class ceiling, so a level-1 Warrior saw plate as - /// usable and the equip Gate then refused it. It now answers from the Character's own training. #[test] fn the_usable_filter_follows_trained_armor_not_the_class_ceiling() { let plate = BrowseFacts { diff --git a/gateway/src/stdb/reads/mail.rs b/gateway/src/stdb/reads/mail.rs index fe88d5001..6285070e4 100644 --- a/gateway/src/stdb/reads/mail.rs +++ b/gateway/src/stdb/reads/mail.rs @@ -206,8 +206,7 @@ impl Coordinator { /// A PK lookup on `game_gameobject`, then the same map/instance/range check /// `module/src/gameobject.rs::usable_go` applies to a chest. It must STAY a PK lookup: /// `game_gameobject` is spatial, and a scan over a sharded table returns a silent subset, so a - /// mailbox would work or not depending on which database the session happens to read - /// (`module/src/mail.rs` pins this by source scan). + /// mailbox availability would depend on which database the session reads. /// /// `false` for an unknown guid, a non-mailbox gameobject, another map or instance, and anything /// out of range — the gate answers the question, and the caller decides what a refusal costs. diff --git a/gateway/src/stdb/reads/npc_vendor.rs b/gateway/src/stdb/reads/npc_vendor.rs index e4d76d1de..5f2636194 100644 --- a/gateway/src/stdb/reads/npc_vendor.rs +++ b/gateway/src/stdb/reads/npc_vendor.rs @@ -306,11 +306,6 @@ impl Coordinator { let mut rows: Vec<_> = db .game_trainer_spell() .iter() - // Profession-learn offerings (learn_skill_line != 0) are now SHOWN: the importer synthesizes - // them with REAL Spell.dbc learn ids (2575 Mining, 2366 Herbalism, …), so the 5875 client - // resolves the name/icon + emits a valid SMSG_LEARNED_SPELL — unlike the old 50080-88 markers - // this filter used to hide. The buy still routes to crate::skill::learn_profession (module - // unchanged). The per-row `known` below grays tiers the player has already trained past. .filter(|t| t.trainer_entry == trainer_entry) .collect(); rows.sort_by_key(|t| t.spell_id); // stable order (SQL has no ORDER BY in 2.5) diff --git a/gateway/src/stdb/reads/quest.rs b/gateway/src/stdb/reads/quest.rs index e8c7ca3a2..fd4e541c7 100644 --- a/gateway/src/stdb/reads/quest.rs +++ b/gateway/src/stdb/reads/quest.rs @@ -109,7 +109,7 @@ impl Coordinator { Ok(quest_detail_view(&guard.conn.db, quest_id)) } - /// The player's active (un-rewarded) quests as quest-log descriptor slots (Phase 2: the L window). + /// The player's active (un-rewarded) quests as quest-log descriptor slots: the quest-log window. /// Deterministic slot assignment (sorted by quest_entry → slot 0..), capped at the 20 vanilla slots; /// each slot carries the quest id, per-objective counts, and a state byte (1 = all objectives met, /// else 0). The gateway encodes these into the `PLAYER_QUEST_LOG_*` fields. RLS-bypassed read. @@ -124,12 +124,6 @@ impl Coordinator { } } -/// Is every objective of `quest_entry` met for this player? MIRRORS the module's `quest_is_complete` -/// (module/src/quest.rs): a COLLECT_ITEM objective reads LIVE INVENTORY (its progress count is never bumped — -/// completion follows the bag), every other kind reads the per-objective progress count. THE FIX for -/// collect-quest turn-in: the gateway used to read `counts` ONLY, so the 121-of-174 collect objectives always -/// showed incomplete → the client's "Complete Quest" button stayed disabled and the zone's quest backbone was -/// un-turn-in-able. A quest with no objectives is trivially complete (a pure talk-to-giver quest). fn quest_objectives_complete( db: &RemoteTables, quest_entry: u32, diff --git a/gateway/src/stdb/reducers.rs b/gateway/src/stdb/reducers.rs index a287d53c1..0b5b8879c 100644 --- a/gateway/src/stdb/reducers.rs +++ b/gateway/src/stdb/reducers.rs @@ -31,9 +31,6 @@ use lyracore_shared::trainer::TrainerRefusal; static NEXT_TAXI_REQUEST_ID: OnceLock = OnceLock::new(); fn next_taxi_request_id() -> u64 { - // Seed from this process start's wall-clock nanoseconds. The reply table survives a gateway - // restart, so restarting the old `1, 2, ...` sequence could make the cache's pre-restart row - // look like the just-committed reply before its replacement subscription delta arrived. let next = NEXT_TAXI_REQUEST_ID.get_or_init(|| { let seed = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -379,8 +376,6 @@ impl Coordinator { })) } - /// Cohesive open request: source discovery and direct-route filtering commit together, then - /// this projects the module's client node ids without re-reading raw taxi tables. pub fn open_taxi( &self, character_guid: u64, @@ -1226,7 +1221,7 @@ impl Coordinator { }) } - /// Enter the world (Phase 4): call the `player_login` reducer on the coordinator connection + /// Enter the world: call the `player_login` reducer on the coordinator connection /// (so `ctx.sender` is the player's bound identity), then read the resulting /// `game_world_entity` row back through the privileged cache as an `EntityView`. pub fn player_login( @@ -1334,7 +1329,7 @@ impl Coordinator { }); } - /// Provision SRP6 credentials computed by the gateway (Phase 0 bring-up). + /// Provision SRP6 credentials computed by the Gateway. pub fn provision_account(&self, username: &str, salt: &[u8], verifier: &[u8]) -> Result<()> { call_reducer!( self.0.call_pipe().conn.reducers, @@ -1442,7 +1437,7 @@ impl Coordinator { Ok(self.realm_core()?.has_auction(character_guid)) } - /// Logon writes K + the bound per-account identity (Phase 1). + /// Logon writes K + the bound per-account identity. pub fn establish_session( &self, account_id: u64, @@ -1770,7 +1765,7 @@ impl Coordinator { ) } - /// Draw or stow the player's weapons (`CMSG_SETSHEATHED`). [#101] + /// Draw or stow the player's weapons (`CMSG_SETSHEATHED`). pub fn set_sheathed(&self, _account_id: u64, actor_guid: u64, state: u8) -> Result<()> { if actor_guid == 0 { return Err(anyhow!("set_sheathed: actor_guid unresolved")); @@ -2075,7 +2070,7 @@ impl Coordinator { /// Deliberately does NOT use the `call_reducer!` macro: that macro wraps a module `Err` as /// `"{what} reducer failed: {e}"` (fine when a caller only reads its Refusal tag), but the Say /// handler relays this `Err`'s text VERBATIM to the sender as a system chat line — a raw - /// `"permission denied"` / `"unknown command: .foo"` must reach the client with no wrapper + /// `"permission denied"` / `"unknown command:.foo"` must reach the client with no wrapper /// prefix. pub(crate) fn request_gm_command( &self, @@ -2165,7 +2160,7 @@ impl Coordinator { )) } - /// `CMSG_INITIATE_TRADE` — `target_guid` is the client's targeted player (#120). + /// `CMSG_INITIATE_TRADE`, `target_guid` is the client's targeted player. pub fn initiate_trade( &self, _account_id: u64, @@ -2183,7 +2178,7 @@ impl Coordinator { ) } - /// `CMSG_BEGIN_TRADE` (#120). + /// `CMSG_BEGIN_TRADE`. pub fn begin_trade(&self, _account_id: u64, actor_guid: u64) -> Result<()> { if actor_guid == 0 { return Err(anyhow!("begin_trade: actor_guid unresolved")); @@ -2196,7 +2191,7 @@ impl Coordinator { ) } - /// `CMSG_CANCEL_TRADE` (#120). + /// `CMSG_CANCEL_TRADE`. pub fn cancel_trade(&self, _account_id: u64, actor_guid: u64) -> Result<()> { if actor_guid == 0 { return Err(anyhow!("cancel_trade: actor_guid unresolved")); @@ -2233,7 +2228,7 @@ impl Coordinator { ) } - /// `CMSG_SET_TRADE_ITEM` (#121). + /// `CMSG_SET_TRADE_ITEM`. pub fn set_trade_item( &self, _account_id: u64, @@ -2252,7 +2247,7 @@ impl Coordinator { ) } - /// `CMSG_CLEAR_TRADE_ITEM` (#121). + /// `CMSG_CLEAR_TRADE_ITEM`. pub fn clear_trade_item( &self, _account_id: u64, @@ -2270,7 +2265,7 @@ impl Coordinator { ) } - /// `CMSG_SET_TRADE_GOLD` (#121). + /// `CMSG_SET_TRADE_GOLD`. pub fn set_trade_gold(&self, _account_id: u64, actor_guid: u64, copper: u32) -> Result<()> { if actor_guid == 0 { return Err(anyhow!("set_trade_gold: actor_guid unresolved")); @@ -2283,7 +2278,7 @@ impl Coordinator { ) } - /// `CMSG_ACCEPT_TRADE` (#122). + /// `CMSG_ACCEPT_TRADE`. pub fn accept_trade(&self, _account_id: u64, actor_guid: u64) -> Result<()> { if actor_guid == 0 { return Err(anyhow!("accept_trade: actor_guid unresolved")); @@ -2296,7 +2291,7 @@ impl Coordinator { ) } - /// `CMSG_UNACCEPT_TRADE` (#122). + /// `CMSG_UNACCEPT_TRADE`. pub fn unaccept_trade(&self, _account_id: u64, actor_guid: u64) -> Result<()> { if actor_guid == 0 { return Err(anyhow!("unaccept_trade: actor_guid unresolved")); @@ -2309,7 +2304,7 @@ impl Coordinator { ) } - /// `CMSG_BUSY_TRADE` (#123). + /// `CMSG_BUSY_TRADE`. pub fn busy_trade(&self, _account_id: u64, actor_guid: u64) -> Result<()> { if actor_guid == 0 { return Err(anyhow!("busy_trade: actor_guid unresolved")); @@ -2322,7 +2317,7 @@ impl Coordinator { ) } - /// `CMSG_IGNORE_TRADE` (#123). + /// `CMSG_IGNORE_TRADE`. pub fn ignore_trade(&self, _account_id: u64, actor_guid: u64) -> Result<()> { if actor_guid == 0 { return Err(anyhow!("ignore_trade: actor_guid unresolved")); @@ -2758,9 +2753,9 @@ impl Coordinator { ) } - /// Respec at a trainer (the "I wish to unlearn my talents." gossip option, #516) — clears every + /// Respec at a trainer (the "I wish to unlearn my talents." gossip option), clears every /// learned talent for the calling player's escalating gold cost. Rides the coordinator - /// connection as `gw_reset_talents` (#483 deleted the per-player sender path). + /// connection as `gw_reset_talents` (deleted the per-player sender path). pub fn reset_talents( &self, _account_id: u64, @@ -4806,7 +4801,7 @@ mod auction_reducer_tests { operation: "gw_auction_hold_listing".to_string(), reason: refusal.as_tag().to_string(), }) - .context("listing phase 1"); + .context("preparing auction listing"); assert_eq!(auction_refusal(&error), Some(refusal)); } diff --git a/gateway/src/stdb/subscriptions.rs b/gateway/src/stdb/subscriptions.rs index b087e61c9..e0e210114 100644 --- a/gateway/src/stdb/subscriptions.rs +++ b/gateway/src/stdb/subscriptions.rs @@ -464,12 +464,6 @@ pub(crate) fn instance_relay_gate(row_instance_id: u64, viewer_instance_id: Opti pub(crate) static MOTION_CALLS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); pub(crate) static MOTION_SENT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); -/// The THIRD hop the two counters above could not see: queued → handed to the -/// writer. Both relays used to push with `let _ = tx.send(..)`, so a send to a session whose writer -/// half was already gone got discarded with no trace — a delivery path degrading silently, the exact -/// failure *shape* the stuck-relay incident and other orphaned writers had. Incremented by BOTH -/// [`relay_entity_motion`] and [`relay_creature_leg`]: they feed the same per-session writer channel, -/// so one counter answers "is the writer still taking peer-motion packets at all". pub(crate) static MOTION_DROPPED: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); @@ -712,18 +706,6 @@ pub(crate) fn relay_corpse_update(row: &Corpse) -> Vec { ))] } -/// The pre-dedup visibility gate `offer_peer_create` runs before it ever touches the `created` set — -/// self-skip, instance isolation, spirit-healer ghost-gating, and the -/// currently-stealthed check. Pure over ALREADY-READ state (the caller does the DB lookups and passes -/// their results in) so it is unit-testable without a live SDK connection — the three scenarios -/// this predicate must get right (re-entry re-creates, login sees pre-existing peers, cross-instance -/// stays excluded) all reduce to "does this predicate return true for this row" plus the dedup-set -/// mechanics exercised separately below. Order/semantics moved verbatim out of the old inline body: -/// - Self is never offered (the dedup set is pre-seeded with `self_guid` instead). -/// - A cross-instance row is excluded BEFORE the dedup insert, so it is never marked `created` even -/// transiently (the same trap the encode-failure rollback below guards against). -/// - A spirit-healer creature is visible ONLY to a GHOST viewer (the released-spirit corpse-run state). -/// - A CURRENTLY-stealthed peer (≥1 A_STEALTH aura) is excluded; the later REVEAL re-offers it. fn peer_create_gate( row_guid: u64, self_guid: u64, @@ -785,12 +767,6 @@ pub(crate) fn chat_range_yd(chat_type: u8) -> f32 { /// `created` dedup, then the CREATE send. Shared by the insert dispatch, the update re-entry branch, /// the recenter's entered-set, and the world-entry sweep. /// -/// `db` is the cache of the shard that HOLDS the row (its aura rows and pet-spell rows live there); -/// `coord` is that shard's handle, used to read the peer's equipped gear RLS-bypassed. The viewer's -/// own instance and ghost state come off [`Viewer`] — never a lookup in `db`, because a row on -/// another shard's cache has no copy of the viewer's row to look them up in (the reason cross-seam -/// visibility ever needed a second per-player connection at all). -/// /// On a `build_peer_create` encode failure the guid is ROLLED BACK out of `created`: leaving it in /// permanently suppresses the peer (a second latent bug in the same re-entry path, below). #[must_use = "these relay bodies RETURN the packets to send — the caller enqueues them; \ @@ -876,8 +852,6 @@ pub(crate) fn offer_peer_create_for( .character_guid() .find(&row.guid) { - // CREATE is already first in `out`. A resident flight must follow it in the same writer - // work item so AOI entry can never observe MONSTER_MOVE for an unknown passenger. append_resident_taxi_after_create(&mut out, &viewer.created, viewer.self_guid, &spline); } out @@ -1166,7 +1140,7 @@ pub(crate) fn relay_gameobject_create( return Vec::new(); }; let view = go_view(row.clone(), &tmpl); - // #515: the CREATE_OBJECT's typed descriptor builder can only carry rot0 of the 4-float spawn + // the CREATE_OBJECT's typed descriptor builder can only carry rot0 of the 4-float spawn // quaternion (gtker's slot-0-only wall), so the other 3 slots — and the yaw-derive fallback for // an all-zero stored quaternion — ride a second raw VALUES frame right behind CREATE. The client // has already materialized the object by the time this second frame arrives (same ordering the @@ -1528,14 +1502,7 @@ pub(crate) fn cast_event_outbound(self_guid: u64, row: &SpellCastEvent) -> Vec0; this slice keeps the `damage > 0` gate, so a 0-damage full-absorb is - // intentionally NOT logged (vanilla's absorb-only "Absorb" text is a separate log variant — - // out of scope). Widen the gate to `|| row.absorbed > 0 || row.resisted > 0` to add it later. + if row.damage > 0 { let log = codec::build_spell_non_melee_damage_log( row.target_guid, @@ -1567,11 +1534,8 @@ pub(crate) fn cast_event_outbound(self_guid: u64, row: &SpellCastEvent) -> Vec 0 { let cd = codec::build_spell_cooldown(row.caster_guid, row.spell_id, row.cooldown_ms); out.push(Outbound::One(ServerOpcodeMessage::SMSG_SPELL_COOLDOWN( @@ -1629,14 +1593,8 @@ pub(crate) fn armor_packet( Box::new(codec::build_armor_values(self_guid, eff, pos)), ))) } -// Live PAPERDOLL STR/AGI/STA/INT/SPI/AP/damage-range on an aura apply/expire (#517, the Battle Shout -// bug): `module::spell::recompute_sheet` is the SOURCE OF TRUTH and already re-derived + wrote -// `game_world_entity.sheet_*` in the SAME reducer transaction that inserted/deleted `changed` — this -// is a plain re-push of that row, never a second aura fold (the trap the ORIGINAL gateway-only -// `sheet_stats` hit: it never read `game_aura` at all). Gated on the aura KIND matching what -// `recompute_sheet` actually reacts to (`aura_moves_sheet`'s mirror), so an unrelated buff/debuff -// (a DoT, a slow) doesn't spam the opcode — purely a re-push filter, not aura interpretation. Self- -// scoped: the sheet shows only your own numbers, so no peer relay is needed. +// The Module has already persisted sheet stats in the aura transaction. Re-push that row +// only for aura kinds that affect it; the paperdoll is private to its owner. pub(crate) fn sheet_packet( coord: &Coordinator, changed: &Aura, @@ -1696,11 +1654,7 @@ pub(crate) fn stealth_visibility( if !created.lock().unwrap().insert(changed.target_guid) { return Vec::new(); } - // "Is the peer in this viewer's scope" used to be "is its row in this - // connection's cache", which the per-player box subscription made equivalent. - // The shared connection's cache holds the whole world, so the question has to be - // put to the cell index instead — otherwise a stealther unstealthing on the far - // side of the zone would CREATE for everyone. + if !view .spatial .can_see(EntityLayer::WorldEntity, session, changed.target_guid) @@ -1939,7 +1893,7 @@ pub(crate) fn aura_snapshot_outbound( out } -/// Trade-status relay (#120): `game_trade_event.kind` → the `SMSG_TRADE_STATUS` variant, to the +/// Trade-status relay : `game_trade_event.kind` → the `SMSG_TRADE_STATUS` variant, to the /// row's recipient and nobody else (audience resolved by the caller, the `auction_notice_outbound` /// shape). The kind byte is `lyracore_shared::trade::event_kind` — NOT the vanilla discriminant; /// this match IS the wire mapping. An unknown kind (a newer module mid-rollout) drops with a warn @@ -1948,11 +1902,11 @@ pub(crate) fn trade_event_outbound(row: &TradeEvent) -> Vec { use lyracore_shared::trade::event_kind as kind; use wow_world_messages::vanilla::SMSG_TRADE_STATUS; // The OFFER_* kinds carry a whole-side snapshot and decode to the fixed-444-byte extended - // status instead of a plain status (#121); `self_player` is the kind, not an inference. + // status instead of a plain status ; `self_player` is the kind, not an inference. if row.kind == kind::OFFER_SELF || row.kind == kind::OFFER_PARTNER { // WIRE POLARITY (mangoszero `SendUpdateTrade`): the byte is `1 means traders data, // 0 means own` — so the field is SET when the packet describes the PARTNER's side, - // despite the binding's `self_player` name. Live-client verification is #124's pass. + // despite the binding's `self_player` name. The protocol's polarity controls this value. return match trade_offer_extended(row.kind == kind::OFFER_PARTNER, &row.payload) { Some(msg) => vec![Outbound::One( ServerOpcodeMessage::SMSG_TRADE_STATUS_EXTENDED(Box::new(msg)), @@ -1983,7 +1937,7 @@ pub(crate) fn trade_event_outbound(row: &TradeEvent) -> Vec { kind::TRADE_ACCEPT => Some(SMSG_TRADE_STATUS::TradeAccept), kind::BACK_TO_TRADE => Some(SMSG_TRADE_STATUS::BackToTrade), kind::TRADE_COMPLETE => Some(SMSG_TRADE_STATUS::TradeComplete), - // Commit refused on bag space (#122): the window closes with the inventory error; + // Commit refused on bag space : the window closes with the inventory error; // `target_error` says WHOSE bags — false = yours, true = the partner's. kind::INV_FULL_SELF => Some(SMSG_TRADE_STATUS::CloseWindow { inventory_result: wow_world_messages::vanilla::InventoryResult::InventoryFull, @@ -2149,7 +2103,7 @@ pub(crate) fn duel_winner_outbound(row: &DuelEvent) -> Vec { ))] } -/// Decode an `OFFER_*` payload into the fixed-444-byte `SMSG_TRADE_STATUS_EXTENDED` (#121): +/// Decode an `OFFER_*` payload into the fixed-444-byte `SMSG_TRADE_STATUS_EXTENDED` : /// counts are 7/7 (the cmangos constant), unused slots stay zeroed (`TradeSlot::default`), and /// every filled slot carries the module-resolved stack/durability/enchant fields. Fails closed /// with the payload decoder. `describes_partner` sets the wire's misnamed `self_player` byte — @@ -2321,10 +2275,7 @@ pub(crate) fn group_event_outbound( } } } - // A grouped money-loot split's per-recipient share → the SAME - // `SMSG_LOOT_MONEY_NOTIFY` the (now-removed) unconditional gateway send used to build, - // just per-recipient and carrying the SHARE instead of the total (`amount` here IS the - // wire field, matching `codec::build_loot_money_notify`'s single `amount: u32`). + roll_kind::MONEY_SHARE => { match lyracore_shared::loot_roll::decode_money_share(&row.payload) { Some(share) => Some(ServerOpcodeMessage::SMSG_LOOT_MONEY_NOTIFY( @@ -2531,10 +2482,6 @@ fn log_unparseable_broadcast(row: &GroupEvent) { ); } -/// The shared-dispatch "who may see this row" predicate for the PRIVATE recipient-addressed families -/// (group/loot-roll/quest-share, resurrect prompt, Auction Notice, Mail Arrival, Realm Chat Line): -/// the row's addressee and nobody else. On the shared feed this, with the owner-session lookup that -/// enforces it structurally, is the entire privacy guarantee RLS used to provide. pub(crate) fn private_recipient_audience(row_recipient_guid: u64, viewer_guid: u64) -> bool { // 0 is "unaddressed"/"uninitialized", never a real character — an equality alone would let an // unaddressed row match a half-initialized viewer (0 == 0), so zero denies on either side. @@ -2954,7 +2901,7 @@ pub(crate) fn creature_leg_outbound( y: row.sy, z: row.sz, }; - // #518: a facing-only leg (the mover doesn't move, `emit_facing_spline`'s carrier) gets the + // a facing-only leg (the mover doesn't move, `emit_facing_spline`'s carrier) gets the // `FacingAngle` variant — the only 1.12 `SMSG_MONSTER_MOVE` shape that turns a STATIONARY // creature without a spline the client would otherwise have nothing to interpolate from. if row.facing { @@ -3016,9 +2963,6 @@ pub(crate) fn creature_leg_outbound( ))] } -/// Append the still-current part of a resident creature leg to its CREATE work item. -/// Live leg callbacks use [`creature_leg_outbound`] unchanged; a late viewer instead starts at the -/// interpolated current point and receives only the remaining duration. fn append_resident_creature_after_create( created_outbound: &mut Vec, created: &Mutex>, @@ -3289,7 +3233,7 @@ fn entity_update_to_outbound_with_dynamic_flags( out.push(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(m))); } } - // Sheath relay (#101): UNIT_FIELD_BYTES_2 byte 0 flipping as a unit draws or stows its weapon. + // Sheath relay : UNIT_FIELD_BYTES_2 byte 0 flipping as a unit draws or stows its weapon. // Any unit, not player-gated — a creature drawing on engage is the same wire field. Without // this arm the server knows the state and no observer ever hears about it. if old.unit_bytes_2 != new.unit_bytes_2 { @@ -4921,16 +4865,16 @@ mod tests { won.loser_name = "Loser".into(); let out = duel_event_outbound(&won, None); assert!(matches!( - out.as_slice(), - [ - Outbound::One(ServerOpcodeMessage::SMSG_DUEL_COMPLETE(_)), - Outbound::One(ServerOpcodeMessage::SMSG_DUEL_WINNER(message)), - .. - // `opponent_name` is written first on the wire and must carry the winner. - ] if message.reason == wow_world_base::shared::duel_winner_reason_vanilla_tbc_wrath::DuelWinnerReason::Won - && message.opponent_name == "Winner" - && message.initiator_name == "Loser" - )); + out.as_slice(), + [ + Outbound::One(ServerOpcodeMessage::SMSG_DUEL_COMPLETE(_)), + Outbound::One(ServerOpcodeMessage::SMSG_DUEL_WINNER(message)), + .. + // `opponent_name` is written first on the wire and must carry the winner. + ] if message.reason == wow_world_base::shared::duel_winner_reason_vanilla_tbc_wrath::DuelWinnerReason::Won + && message.opponent_name == "Winner" + && message.initiator_name == "Loser" + )); let mut fled_event = duel_event(3, 3); fled_event.winner_guid = 10; @@ -4960,7 +4904,7 @@ mod tests { )); } - /// The trade-status wire mapping (#120): every `lyracore_shared::trade::event_kind` the module + /// The trade-status wire mapping : every `lyracore_shared::trade::event_kind` the module /// emits decodes to its `SMSG_TRADE_STATUS` variant — `BeginTrade` carrying the counterparty /// guid the client needs to open the window — and an unknown kind (newer module mid-rollout) /// drops rather than desyncing the trade window. @@ -5176,7 +5120,7 @@ mod tests { ); } - /// The OFFER_* kinds decode to the fixed-444-byte extended status (#121): the polarity byte + /// The OFFER_* kinds decode to the fixed-444-byte extended status : the polarity byte /// comes from the KIND (never inferred), the window-visible item fields survive the payload /// round-trip into the right wire slots, unused slots stay zeroed, and a malformed payload /// drops the packet entirely. @@ -5862,7 +5806,7 @@ mod tests { ); } - /// #101: a sheath change must relay, or drawing a weapon is invisible to everyone but the player + /// a sheath change must relay, or drawing a weapon is invisible to everyone but the player /// who did it. The state lives in BYTE 0 of `unit_bytes_2` — pin the decoded packet, and pin that /// an unchanged row stays silent (the client re-sends its current state on every weapon swap, and /// a relay per no-op is a broadcast amplifier on a busy cell). @@ -6239,9 +6183,6 @@ mod tests { #[test] fn taxi_landing_emits_one_atomic_presentation_update_not_a_land_mount_duplicate() { - // The taxi-landing twin of the activation test above: display AND the flight bit clear - // TOGETHER. Must relay through the SAME coupled builder, never the standalone land-mount one - // alongside it (the double-fire this ticket's diff separation must avoid). let mut old = player_entity(); old.mount_display_id = 1147; old.unit_flags |= lyracore_shared::constants::unit_flags::TAXI_FLIGHT; @@ -6972,7 +6913,6 @@ mod tests { const SPIRITHEALER: u32 = lyracore_shared::constants::npc_flags::SPIRITHEALER; // A spirit healer is invisible to a living viewer... assert!(!peer_create_gate(2, 1, 0, 0, SPIRITHEALER, false, false)); - // ...but visible to a ghost. assert!(peer_create_gate(2, 1, 0, 0, SPIRITHEALER, true, false)); // A non-spirit-healer entity is unaffected by the viewer's ghost state. assert!(peer_create_gate(2, 1, 0, 0, 0, false, false)); @@ -7545,9 +7485,6 @@ mod tests { ); } - /// **Fan-out collapse.** Both relays push with a discarding send; a session whose writer half is - /// already gone used to swallow the packet with no trace. Both discard sites are driven here - /// (a dropped receiver is exactly the "writer gone" case) and the counter must move for each. /// /// Deltas rather than absolutes because the counter is a process-global atomic; this is the only /// test that increments it, so the deltas are exact. @@ -7613,7 +7550,7 @@ mod tests { ); } - /// #518: a `facing`-flagged `game_creature_spline` row relays as `SMSG_MONSTER_MOVE`'s + /// a `facing`-flagged `game_creature_spline` row relays as `SMSG_MONSTER_MOVE`'s /// `FacingAngle` variant, not the `Normal` one every other leg uses — the whole point being that /// a stationary creature otherwise has no wire-level way to tell the client its heading changed. #[test] diff --git a/gateway/src/stdb/world_index.rs b/gateway/src/stdb/world_index.rs index aedddc423..503981726 100644 --- a/gateway/src/stdb/world_index.rs +++ b/gateway/src/stdb/world_index.rs @@ -1,52 +1,4 @@ -//! The in-process AOI index — the gateway-side replacement for ~600 per-player, -//! box-scoped SpacetimeDB subscriptions. -//! -//! # Why this exists -//! -//! Before the shared-connection model, every session opened its own SpacetimeDB connection and -//! subscribed a 5×5 grid box over four tables (`game_world_entity`, `game_gameobject`, `game_entity_motion`, -//! `game_creature_spline`), re-subscribing on every cell crossing. At 600 players that is ~600 -//! distinct query strings that can neither be shared nor pruned, and every committed transaction -//! woke ~600 SDK pumps to evaluate them — measured as load average 11–14 on 8 cores with only 7 -//! runnable threads and 34% CPU, i.e. a WAKEUP problem, not a CPU problem (SpacetimeDB's own -//! clockworklabs/SpacetimeDB#2783). -//! -//! Now the four tables ride ONE global subscription per shard, on that shard's coordinator -//! connection, and this structure answers the question the subscription engine used to answer: -//! **which sessions can see this row?** -//! -//! # The shape, and why it is this shape -//! -//! Interest is a SYMMETRIC square: a viewer anchored on cell `A` sees exactly the cells within -//! `±BOX_HALF_SPAN` of `A` on each axis (`lyracore_shared::spatial::GridBox::bounds`). Symmetry is -//! what makes the inverse cheap — "who sees cell `E`" is "every viewer anchored within ±SPAN of -//! `E`", the same 25-cell neighbourhood walked in the other direction. So both directions are a -//! fixed 25 hash lookups plus the size of the answer, INDEPENDENT of how many players are online: -//! -//! | operation | cost | -//! |---|---| -//! | [`WorldIndex::upsert_entity`] (a row moved / appeared) | O(1) | -//! | [`WorldIndex::viewers_of`] (row → sessions) | O(25 + V), V = viewers that can see it | -//! | [`WorldIndex::move_viewer_delta`] (a cell crossing) | O(50 + delta) — 5 cells in, 5 out on an axis crossing | -//! | [`WorldIndex::visible_entities`] (the login sweep) | O(25 + E), E = entities in view | -//! -//! A naive "for each session, is this row in its box" scan would be O(players) PER ROW UPDATE, -//! which at 600 players and ~2 movement heartbeats/s is the same fan-out problem in a different -//! process. That shape is deliberately not available here. -//! -//! # Shards -//! -//! Guids are globally unique across databases, so every shard's coordinator stream -//! feeds ONE index keyed by `(map_id, instance_id, cell)` — one view spans the whole realm. Each -//! entity remembers which shard's cache holds its row ([`ShardId`]) so a sweep can read the row -//! back without searching every shard. -//! -//! # The escape hatch -//! -//! `LYRACORE_AOI=0` (`config::aoi_enabled`) keeps its old meaning — no cell scoping, every viewer -//! sees the whole partition. That path is deliberately O(viewers) / O(entities): it reproduces the -//! cost profile of the global subscription it stands in for, and it is a debugging hatch, not a -//! production mode. +//! Indexed snapshots of subscribed World Shard rows. use lyracore_shared::spatial::{grid_cell, grid_cell_id, BOX_HALF_SPAN}; use std::collections::{HashMap, HashSet}; @@ -452,12 +404,6 @@ impl WorldIndex { /// Move a viewer's anchor and report the VISIBILITY delta the move implies, or `None` when the /// anchor did not change (the overwhelmingly common per-heartbeat call — a plain hash lookup and /// a compare, no allocation). - /// - /// The subscription engine used to produce this delta itself, as the `on_insert`/`on_delete` - /// pair a re-subscribed box generated. Computed by diffing the two 25-cell neighbourhoods and - /// gathering guids only from the cells that actually differ: an axis crossing touches 5 cells in - /// and 5 out, so the answer costs ~50 hash lookups plus the size of the answer — never a scan of - /// the world or of the box's contents. pub fn move_viewer_delta(&self, session: SessionId, key: CellKey) -> Option { let mut inner = self.lock(); let old = *inner.viewer_cell.get(&session)?; @@ -578,13 +524,6 @@ mod tests { // =========================================================================================== // Fixture + the SQL ORACLE. // - // The oracle is the SUBSCRIPTION SQL ITSELF — `box_queries` builds the exact WHERE clause the - // per-player box subscription used to send, and `sql_selects` below EVALUATES that text. - // Deliberately a parser rather than a re-implementation of the predicate: a re-implementation - // would drift with the index it is supposed to be independent of, and the class of bug this - // whole test exists to catch (a peer that never appears, or one visible through a wall) is - // exactly the class where index and predicate disagree by one cell. - // =========================================================================================== #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct Row { @@ -792,10 +731,6 @@ mod tests { } } - /// The recenter delta must be exactly the difference between the two SQL-defined visible sets. - /// This is the half of the change the subscription engine used to do for us: re-subscribing a - /// box produced the enter/leave events itself, and getting the delta wrong here is invisible in - /// review and shows up live as a peer that never despawns, or one that never comes back. #[test] fn a_recenter_delta_is_exactly_the_difference_between_the_two_sql_visible_sets() { let mut rng = Rng(0xDEAD_BEEF_0000_0007); diff --git a/gateway/src/stdb/world_store.rs b/gateway/src/stdb/world_store.rs index d14e6a527..cca23fda5 100644 --- a/gateway/src/stdb/world_store.rs +++ b/gateway/src/stdb/world_store.rs @@ -74,12 +74,6 @@ impl WorldStore for Coordinator { // path: the escrowed transfer moves the character rows, instance entry drives it, // and the arriving shard's `establish_session` (or `player_login`'s restamp) does the binding. fn home_shard(&self, character_guid: u64) -> Option> { - // The single-shard short-circuit, the realm-core index hint, the confirming - // probe and the self-heal write-back — all of it in `realm_core::settle_shard_index`, - // written against the `RealmDb` trait so the tests can execute THIS decision (index reads, - // probe order, heal write and the "one database costs zero reads" property) without a live - // node. It was unreachable by any test while it lived inline here, and a mutation review - // left two surviving mutations in it for exactly that reason. let resolved = crate::realm_core::settle_shard_index(self, character_guid)?; // The handle comes from `shard_for`, unchanged: it resolves the same location // through the same map and returns `None` when that is already this handle's shard. All @@ -325,11 +319,6 @@ impl WorldStore for Coordinator { self.establish_session(account_id, session_key, identity) } - /// The world handshake's account→K lookup, split across the two databases that own the two - /// halves of the answer. The body is `realm_core::lookup_session`, written against the - /// `RealmDb` trait so the split it performs — K from realm-core, the account id from THIS world - /// shard — is executed by a test rather than only described by a comment. Both of those were - /// surviving mutations in a mutation review. fn lookup_session(&self, account_name: &str) -> Result> { crate::realm_core::lookup_session(self, account_name) } @@ -403,19 +392,9 @@ impl WorldStore for Coordinator { crate::realm_core::publish_bot_shard_index(self, intent) } - /// The character-select list, UNIONED across every connected shard. - /// - /// A character that logged out inside a dungeon lives on the instance shard, not the realm - /// database — and Phase A has no realm-core character index to ask, so the gateway asks every - /// shard. The escrow guarantees a character has exactly one durable copy once a transfer has - /// settled, so the union is the list; the `guid` dedup covers the seconds-long window where an - /// interrupted transfer left a frozen copy on both sides (first hit wins, and the source is - /// listed first because `all_shards` puts the default database first). - /// - /// Single-shard → one read of this handle's own rows. - /// - /// Each Character's guild id comes from Realm-core membership. When Realm-core cannot answer, - /// the list still loads, with no guild names. + /// Union the account's Characters across connected World Shards. Deduplicate by guid + /// during interrupted Transfers, keeping the first copy in default-first probe order. + /// Guild membership comes from Realm-core; an unavailable authority leaves guild names empty. fn characters(&self, account_id: u64) -> Result> { let mut out: Vec = if self.is_sharded() { let mut out: Vec = Vec::new(); diff --git a/gateway/src/stdb/world_view.rs b/gateway/src/stdb/world_view.rs index e9152778b..e9b2b72d2 100644 --- a/gateway/src/stdb/world_view.rs +++ b/gateway/src/stdb/world_view.rs @@ -561,9 +561,9 @@ impl WorldView { ) -> Result<()> { let shards = self.shards.read().unwrap(); let shard = shards - .iter() - .position(|registered| registered.shard_name() == coord.shard_name()) - .ok_or_else(|| { +.iter() +.position(|registered| registered.shard_name() == coord.shard_name()) +.ok_or_else(|| { anyhow!( "cannot register viewer {}: coordinator shard `{}` is absent from the world-view shard table ({:?})", viewer.session, @@ -1004,12 +1004,6 @@ fn register_shard_callbacks( move |v, row| melee_disengaged(v, shard, row), ); - // ---- recipient-keyed private event relays ----------------------------------------------- - // The PRIVATE tier: every row is addressed to exactly one recipient, and on this feed the - // gateway owns the guarantee RLS used to give. Delivery is recipient-keyed (the owner-session - // lookup) + the explicit `private_recipient_audience` predicate — never a viewer fan. - // (The realm-core group and chat twins for CROSS-shard delivery ride the same dispatchers, - // armed once per realm-core connection by `arm_realm_private` below.) wire_insert_live( db.game_resurrect_request(), "game_resurrect_request.insert", @@ -1266,11 +1260,11 @@ fn register_shard_callbacks( ); } -/// Register the cross-shard PRIVATE-tier twins (#22 → #483) on the REALM-CORE connection: group +/// Register the cross-shard private relays on the REALM-CORE connection: group /// events, Realm Chat Lines (whispers included), Channel Notices and Mail Arrivals written /// realm-side for recipients whose home shard is elsewhere. Same recipient-keyed dispatchers as /// `arm_shard`'s private tier, armed ONCE per realm-core connection instead of once per session; -/// the last per-session registrations are gone (#483). +/// the last per-session registrations are gone. /// /// Only called when realm-core is a DISTINCT database (`Coordinator::connect` gates it): on a /// single-database gateway the world shard's own `arm_shard` registration already watches these @@ -1450,7 +1444,7 @@ fn guarded(what: &str, body: impl FnOnce()) { } // ----------------------------------------------------------------------------------------------- -// Registration helpers — the clone-per-callback + `guarded(label, ...)` shape that every +// Registration helpers, the clone-per-callback + `guarded(label,...)` shape that every // `arm_shard` registration repeated verbatim, factored to one line per callsite. `T::Row` and // `EventContext` are pinned to the module's generated types, so a mismatched table/body pairing // is a compile error, not a mislabeled log line. @@ -1583,13 +1577,8 @@ fn entity_appeared(view: &Arc, shard: ShardId, row: &WorldEntity) { } } -/// A row changed. Three audiences, decided from the cell delta: -/// -/// * sessions that can see the NEW cell get the same "create if unseen, else diff" treatment the -/// per-player `on_update` gave them (the re-entry-as-update branch, unchanged); -/// * sessions that could see the OLD cell but not the new one get a DESTROY — the packet the SDK -/// used to produce as an `on_delete` when a row left a box; -/// * nobody else is touched. +/// Create or update for the new cell's viewers, destroy for viewers left behind, +/// and leave all other viewers untouched. fn entity_changed(view: &Arc, shard: ShardId, old: &WorldEntity, new: &WorldEntity) { let key = entity_key(new); let previous = view.spatial.upsert_entity( @@ -2727,7 +2716,7 @@ fn mail_arrived(view: &WorldView, row: &MailArrival) { } /// A trade status landed → the `SMSG_TRADE_STATUS` packet to the row's RECIPIENT and nobody -/// else (same shape as [`resurrect_offered`]) (#120). +/// else (same shape as [`resurrect_offered`]). fn trade_event_appeared(view: &WorldView, shard: ShardId, row: &TradeEvent) { let Some(viewer) = view.viewer_of_owner_on_shard(shard, OwnerGuid(row.recipient_guid)) else { return; @@ -4017,12 +4006,6 @@ mod family_audience_tests { assert_eq!(reads.load(Ordering::Relaxed), 1); } - /// The addressed path finds the actor's session by guid alone, with no membership check, so - /// its job is the one place that must refuse a recipient whose membership row is already - /// gone by the time the job runs. `map_or(0, ...)` used to turn a miss into rank 0 — Guild - /// Master, holding every right including VIEWOFFNOTE — so a member who left or was kicked - /// while this job sat in the queue would receive the officer roster. `unreachable_snapshot_read` - /// also proves the guard runs before the snapshot read it would otherwise share. #[test] fn roster_to_actor_with_no_membership_row_sends_nothing() { use lyracore_shared::guild::event_kind; @@ -4041,7 +4024,7 @@ mod family_audience_tests { assert!(queued_job(&actor_rx).is_empty()); } - /// The same guard also has to check which Guild the membership row names: `map_or(0, ...)` + /// The same guard also has to check which Guild the membership row names: `map_or(0,...)` /// ignored the returned guild_id, so a member who moved to another Guild between this job's /// enqueue and its run would still get a roster, rendered under its new Guild's own Rank /// Rights. @@ -5773,9 +5756,6 @@ mod account_claim_relay_tests { ); } - /// **The bug this file's review round fixed:** `character_online_changed` fires with the - /// JUST-COMMITTED row's own level/zone/class, never a stale pre-login value, and marks the - /// Character online for the later claim close to find. #[test] fn character_online_changed_reaches_a_same_team_friend_with_the_just_committed_row() { let view = WorldView::new(true); @@ -5855,12 +5835,6 @@ mod account_claim_relay_tests { assert!(!view.take_character_online(FRIEND)); } - /// **The abandoned-transfer case a review round flagged:** a rolled-back same-database - /// transfer leaves `online: true` with no live entity — `freeze_live_entity` never sets - /// `online` false, and the reaper's rollback only deletes the escrow, not the Character's - /// stuck row. A plain `old.online` check would read true on both sides of the Character's - /// real next login and never fire. The login's own `session_start_micros` stamp still - /// changes — 0 (zeroed by the freeze) to a real timestamp — so the edge fires anyway. #[test] fn character_online_changed_fires_for_a_real_login_even_when_online_was_stuck_true() { let view = WorldView::new(true); @@ -6064,10 +6038,6 @@ pub(super) fn enqueue( /// Move a session's anchor and relay the visibility delta the move implies: a CREATE for everything /// that entered the box, a DESTROY for everything that left it. -/// -/// The subscription engine used to produce these as `on_insert`/`on_delete` when a box was -/// re-subscribed. Runs on the session's OWN movement thread — never on a pump — and does no I/O: -/// the entering rows are read out of the shard coordinator caches already in memory. pub(crate) fn recenter(view: &Arc, viewer: &Arc, map_id: u32, x: f32, y: f32) { let key = CellKey::of_position(map_id, viewer.instance_id, x, y); let Some(delta) = view.spatial.move_viewer_delta(viewer.session, key) else { diff --git a/gateway/src/stdb/world_view/cell_audience_tests.rs b/gateway/src/stdb/world_view/cell_audience_tests.rs index 168a6a629..a8bc5e2f8 100644 --- a/gateway/src/stdb/world_view/cell_audience_tests.rs +++ b/gateway/src/stdb/world_view/cell_audience_tests.rs @@ -1,9 +1,4 @@ //! The cell-anchored relays against the whole-shard-then-gate path they replaced. -//! -//! Every relay here used to enqueue one job per viewer on the shard and let the job's gate reject -//! the row. Now the actor's cell picks the candidates first. The property under test, over -//! randomised realms: filtering the candidates through the same gate yields exactly the set the -//! whole-shard path yielded, and the candidates are fewer than the shard. use super::*; use crate::codec::property_tests::Rng; diff --git a/gateway/src/world/coalesce.rs b/gateway/src/world/coalesce.rs index f25832880..13c1f6a38 100644 --- a/gateway/src/world/coalesce.rs +++ b/gateway/src/world/coalesce.rs @@ -1,11 +1,3 @@ -//! Per-connection movement coalescing. Every inbound `MSG_MOVE_*` used to become -//! its own `movement_update` reducer transaction, so movement dominates the serialized stream as -//! players scale. `CoalesceState` cuts pure-heartbeat volume ~3-5x while keeping every -//! STATE-CHANGE (start/stop/turn/jump/fall-land/strafe, or ANY packet whose flags or heading -//! differ from the last one actually forwarded) byte-identical and undelayed — peers key -//! animation/extrapolation off state changes, not heartbeats, so perceived smoothness is -//! unaffected (a real client's own heartbeat cadence is far coarser than this window; it -//! interpolates from pos+heading+speed between them regardless of the wire cadence to the module). //! //! The decision logic is a PURE state machine (no I/O, no real clock) per this repo's testing //! convention ("extract pure functions and test those"): `now_ms` is diff --git a/gateway/src/world/handlers/char.rs b/gateway/src/world/handlers/char.rs index 136b2c221..b34eabf0b 100644 --- a/gateway/src/world/handlers/char.rs +++ b/gateway/src/world/handlers/char.rs @@ -50,7 +50,7 @@ fn abort_pending_transfer( } } -/// Build + send the player's quest-log descriptor fields as a raw VALUES update (Phase 2) — the +/// Build + send the player's quest-log descriptor fields as a raw VALUES update, the /// world-entry copy of the block, sent right after the self CREATE. A no-op when the gate is off /// or the player has no active quests (`quest_log_update` returns an empty batch, so the CREATE /// packet's already-zeroed fields stand). The in-session relay on accept / progress / turn-in is @@ -214,7 +214,7 @@ fn enter_world( open_loot: OpenLootState::default(), ranged_repeat: false, }); - // Phase 2: the quest-log window. Sent as a separate raw VALUES update AFTER the CREATE + //: the quest-log window. Sent as a separate raw VALUES update AFTER the CREATE // (gtker's CREATE can't carry these walled fields), gated behind LYRACORE_QUEST_LOG until verified. send_quest_log(tx, store, character_guid)?; // If the player carries ammo (a Projectile item, class 6), tell the client it's loaded @@ -266,7 +266,7 @@ pub(crate) fn handle_char( msg: ClientOpcodeMessage, ) -> Result> { match msg { - // Phase 3 (§4): character-select screen. + // (§4): character-select screen. ClientOpcodeMessage::CMSG_CHAR_ENUM => { let characters = store.characters(conn.account_id)?; let enum_msg = codec::build_char_enum(&characters)?; @@ -336,7 +336,7 @@ pub(crate) fn handle_char( )), )?; } - // Phase 4 (§5): enter world -> register peer subscriptions, then login sequence + self + // (§5): enter world -> register peer subscriptions, then login sequence + self // CREATE_OBJECT2 as one contiguous batch (so an async peer event can't splice into it). ClientOpcodeMessage::CMSG_PLAYER_LOGIN(p) => { let character_guid = p.guid.guid(); @@ -415,10 +415,7 @@ pub(crate) fn handle_char( // (`SMSG_TRANSFER_ABORTED`), THEN end the session. Nothing durable is lost — // the escrow is idempotent and the next login re-drives it from the same rows. // - // The guard covers the WHOLE world-port, not just its routing step (adversarial - // review): re-entry can fail on its own — `player_login` refused by the - // stranding guard, a subscription that would not register — with the client on - // exactly the same loading screen, and that window is the wider of the two. + let mut ported = conn.route_home(store, character_guid); if ported.is_ok() { ported = on_home_shard!(conn, store, |st| enter_world( @@ -436,7 +433,7 @@ pub(crate) fn handle_char( } } } - // Phase 7: graceful in-game Logout/Exit. Deny if in combat (vanilla behaviour); otherwise + //: graceful in-game Logout/Exit. Deny if in combat (vanilla behaviour); otherwise // ack instantly + complete, remove the entity (observers see DESTROY), drop the peer // subscriptions, and return to character-select with the connection still open. ClientOpcodeMessage::CMSG_LOGOUT_REQUEST => { diff --git a/gateway/src/world/handlers/combat.rs b/gateway/src/world/handlers/combat.rs index e26269fdf..3cfbae8b5 100644 --- a/gateway/src/world/handlers/combat.rs +++ b/gateway/src/world/handlers/combat.rs @@ -41,7 +41,7 @@ pub(crate) fn handle_combat( // consumed here (rather than falling through to the dispatch tail's `log::debug!` "ignoring" // line) so a `.speed` never spams the log or risks a future desync-classifier false-positive. ClientOpcodeMessage::CMSG_FORCE_RUN_SPEED_CHANGE_ACK(_) => {} - // Draw / stow weapons (#101). The client sends this on `Z`, on a weapon swap, and when an + // Draw / stow weapons. The client sends this on `Z`, on a weapon swap, and when an // ability auto-draws. It is a pure render-state change: nothing gates on it, so a failure is // logged and dropped rather than being session-fatal like ATTACKSWING/ATTACKSTOP. gtker // already parsed the payload into a `SheathState`, so the byte reaching the module is one of diff --git a/gateway/src/world/handlers/guild.rs b/gateway/src/world/handlers/guild.rs index 2d05eebdb..e794c23df 100644 --- a/gateway/src/world/handlers/guild.rs +++ b/gateway/src/world/handlers/guild.rs @@ -812,12 +812,6 @@ fn disband_outbound( /// The shape CMSG_GUILD_REMOVE, PROMOTE, DEMOTE and LEADER share: resolve `typed_name` against the /// actor's own Guild roster, then run the Durable Request and map its outcome /// (`cm:GuildHandler.cpp:146-152,290-296,343-349,467-473`). -/// -/// No match, or two members sharing one name snapshot, sends guid 0 rather than answering locally. -/// mangos checks the actor's Rank Right before it looks the target up, so an unprivileged actor -/// hears its Refusal even when the typed name matches nobody; only the Module's Gate order can -/// reproduce that, since it alone knows the actor's Rank Rights. The Module's own membership lookup -/// then refuses guid 0 as `TargetNotInGuild`, which maps to the same reply this used to send here. fn named_member_op( store: &St, player: GuildActionPlayer, @@ -994,12 +988,6 @@ fn info_text_outbound( /// CMSG_GUILD_SET_PUBLIC_NOTE / SET_OFFICER_NOTE, shared: resolve `target_name` against the /// actor's own Guild through its member name snapshots (`member_by_name`), same as mangos' /// `GetMemberSlot` (`cm:GuildHandler.cpp:526-545,564-582`), then run the edit. -/// -/// No match sends guid 0 rather than answering locally: mangos checks the actor's Rank Right -/// before it looks the target up, so an actor without the right hears `NoPermission` even for a -/// name that matches nobody, and only the Module's own Gate order can reproduce that. The Module's -/// membership lookup then refuses guid 0 as `TargetNotInGuild`, which maps to the same reply this -/// used to send here. fn set_note_outbound( store: &St, player: GuildActionPlayer, diff --git a/gateway/src/world/handlers/query.rs b/gateway/src/world/handlers/query.rs index 5dc6ea970..eac95dba4 100644 --- a/gateway/src/world/handlers/query.rs +++ b/gateway/src/world/handlers/query.rs @@ -23,7 +23,7 @@ fn filtered_gossip_options( // GossipHello, not via a `conditions` row — see `gossip_option::UNLEARNTALENTS`'s doc), so it // needs its own level check here rather than falling through `option_condition_holds`. Below // level 10 a character literally cannot have a talent point, so the option would be inert even - // if shown (#516). + // if shown. let level = store .character_by_guid(player_guid)? .map(|c| c.level) @@ -189,7 +189,7 @@ pub(crate) fn handle_query( WorldState::InWorld(iw) => quest::gossip_quest_items(store, npc, iw.self_guid)?, WorldState::CharSelect => Vec::new(), }; - // A vendor that ALSO has the gossip bit gets a "browse goods" menu entry (rank-vendor #6); + // A vendor that ALSO has the gossip bit gets a "browse goods" menu entry (rank-vendor); // having stock is the is-vendor signal, so no npc_flags read is needed. An innkeeper gets a // "Make this inn your home." entry (hearthstone bind) — that one DOES need the npc_flags // read. Both are APPENDED to the imported options rather than replaced by them: a dump menu @@ -285,7 +285,7 @@ pub(crate) fn handle_query( )?; } Some(gossip_option::UNLEARNTALENTS) => { - // Respec (#516). Errors (out of range / not enough gold) are per-action — + // Respec. Errors (out of range / not enough gold) are per-action , // the window closes either way, same as bind_home above. let _ = store.reset_talents(conn.account_id, player_guid, npc); send(tx, Outbound::One(ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE))?; @@ -329,12 +329,6 @@ pub(crate) fn handle_query( // or `/e` line in a language the speaker's race does not know answers "You don't know that // language"; every other Refusal is silent, matching vanilla. // - // GM playtest dot-commands: a Say line starting with `.` diverts BEFORE - // `send_chat` — never broadcast, never inserted as a `game_chat_event` row — straight to the - // module's one generic `gm_command` reducer. A reducer `Err` (bad gm_level, unknown command, - // bad args) is relayed back to the SENDER ONLY as a system chat line (`SMSG_MESSAGECHAT` - // System); success has no reply (the command's own effect — a teleport, a stat change — is - // its own feedback). ClientOpcodeMessage::CMSG_MESSAGECHAT(c) => { let CMSG_MESSAGECHAT { chat_type, diff --git a/gateway/src/world/handlers/trade.rs b/gateway/src/world/handlers/trade.rs index f7e20d9b7..e2556a06e 100644 --- a/gateway/src/world/handlers/trade.rs +++ b/gateway/src/world/handlers/trade.rs @@ -1,4 +1,4 @@ -//! Trade family (#120): the Trade Session handshake — initiate / begin / cancel. The module owns +//! The Trade Session handshake, initiate / begin / cancel. The module owns //! every gate; ALL statuses, to both parties, ride the `game_trade_event` relay //! (`stdb::subscriptions::trade_event_outbound`), so no arm here answers synchronously. Reducer //! rejections are transient per-action failures (logged, never session-fatal), the vendor-arm @@ -48,7 +48,7 @@ pub(crate) fn handle_trade( } Ok(None) } - // Offer mutations (#121). The (bag, slot) pair maps onto the module's absolute slots the + // Offer mutations. The (bag, slot) pair maps onto the module's absolute slots the // same way the item family does: only the main pseudo-bag (255) is modelled — items inside // equipped sub-bags are logged + ignored, matching the item-action dispatcher's posture. ClientOpcodeMessage::CMSG_SET_TRADE_ITEM(c) => { @@ -93,7 +93,7 @@ pub(crate) fn handle_trade( } Ok(None) } - // Accept / unaccept (#122). The accept body's `unknown1` is padding (vmangos skips it; + // Accept / unaccept. The accept body's `unknown1` is padding (vmangos skips it; // bots set 1) — dropped here, the module needs only who accepted. ClientOpcodeMessage::CMSG_ACCEPT_TRADE(_) => { if let Some(me) = self_guid(conn) { @@ -117,7 +117,7 @@ pub(crate) fn handle_trade( } Ok(None) } - // Proposal declines (#123): the client auto-answers a BeginTrade it can't take — busy + // Proposal declines : the client auto-answers a BeginTrade it can't take, busy // (already in a dialog) or the initiator is on the ignore list. ClientOpcodeMessage::CMSG_BUSY_TRADE => { if let Some(me) = self_guid(conn) { diff --git a/gateway/src/world/handlers/trainer.rs b/gateway/src/world/handlers/trainer.rs index ceea7641d..6007151e5 100644 --- a/gateway/src/world/handlers/trainer.rs +++ b/gateway/src/world/handlers/trainer.rs @@ -200,13 +200,7 @@ pub(crate) fn handle_trainer( )), )?; } - // Live talent-pane refresh (user bug: "talents work server-side but the UI - // doesn't update"). The 1.12 TalentFrame redraws on exactly two events, and a - // PASSIVE pick used to send neither: (a) SPELLS_CHANGED — the pane derives a - // talent's shown rank from which RANK-SPELL is in the spellbook, so relay the - // rank-spell the module just taught (SUPERCEDED replaces the previous rank's - // book entry, same cmangos old-then-new wire order as the trainer path); - // (b) CHARACTER_POINTS_CHANGED — push the decremented unspent counter. + if let WorldState::InWorld(iw) = &conn.state { let self_guid = iw.self_guid; let (teach, superseded, remaining) = diff --git a/gateway/src/world/login_queue.rs b/gateway/src/world/login_queue.rs index f7ea5edfa..f4db4720d 100644 --- a/gateway/src/world/login_queue.rs +++ b/gateway/src/world/login_queue.rs @@ -1,43 +1,3 @@ -//! FIFO admission gate for world-session establishment: a login storm arrives faster than -//! the writer can absorb enter-world/subscription-setup cost — measured 2026-07-30 at 500 sessions, -//! 40 ms stagger: `subscribe` alone was 38% of the writer, 148 reducer calls timed out, and 233 of -//! 500 players were silently dropped (`create_character`/`establish_session`/`release_transfer`/ -//! `set_target` all among the timeouts). Rather than let every connection race straight into -//! `CMSG_PLAYER_LOGIN`, cap how many world sessions the gateway holds open at once; anyone over the -//! cap queues, holding their socket and rendering the client's NATIVE "Position in queue: N" screen -//! (`SMSG_AUTH_RESPONSE`'s `AuthWaitQueue { queue_position }` variant — the same message vmangos/ -//! cmangos use) until a seat opens up. Nobody is dropped; the storm degrades into a wait instead. -//! -//! **Gate point**: `world_handshake` in `world/mod.rs`, right after the SRP client proof validates -//! (so the header cipher already exists — every packet from here on, including the queued resends, -//! must be encrypted) but BEFORE `AUTH_OK`. That is before the client can even reach -//! `CMSG_CHAR_ENUM`, let alone `CMSG_PLAYER_LOGIN`'s `subscribe_player_events` — the actual -//! expensive part measured above. NOT the logon tier (SRP + realm list on 3724): that authenticates -//! the account, not world capacity, and stays ungated. -//! -//! **Config** (both env vars, both default `0` = unlimited — an unconfigured gateway, including -//! every single-player dev realm, never queues anyone): -//! - `LYRACORE_MAX_SESSIONS` — the seat cap. A seat is held from admission (`AUTH_OK`) until the socket -//! closes, covering character-select browsing as well as in-world play: the cheapest place to -//! stop a storm is before any of it starts, not just before the expensive part. -//! - `LYRACORE_ADMIT_CONCURRENCY` — an ADDITIONAL rate limit on how many queued sessions may be admitted -//! per tick (1s), independent of how many seats happen to be free at once. Without it, a mass -//! simultaneous disconnect (a raid wipe, a restart draining a full queue) frees N seats in the -//! same instant and admits the entire backlog at once — reproducing the exact storm this exists -//! to prevent, just one hop later. With it, admissions trickle even when capacity allows more. -//! -//! Deliberate simplification: no priority tiers, no persistence. A disconnected queued socket just -//! leaves the line ([`LoginQueue::cancel`]); a reconnect gets a fresh ticket at the back. -//! -//! **Both knobs are per-process, not realm-wide (#309).** This queue lives in one gateway's memory: -//! `active` counts only sessions admitted through *this* process, so `N` gateways each configured -//! with `LYRACORE_MAX_SESSIONS=500` admit `500*N` sessions realm-wide, and QUEUESTAT depth is -//! per-gateway. That's a legitimate reading — the cap guards this process's own egress, a real -//! per-process resource — but the storm this queue exists to survive (#180) is a *writer* problem -//! shared across every gateway in the realm; a multi-gateway deployment must divide its intended -//! realm-wide ceiling by the gateway count when setting `LYRACORE_MAX_SESSIONS` / -//! `LYRACORE_ADMIT_CONCURRENCY`. See `docs/architecture.md` §3.2. - use std::collections::VecDeque; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Mutex; @@ -308,7 +268,7 @@ mod tests { assert_eq!( admitted, CAP, "with {ARRIVALS} simultaneous arrivals and {CAP} seats, exactly {CAP} must be admitted \ - — fewer means seats were stranded, more means the cap was breached under contention" + — fewer means seats were stranded, more means the cap was breached under contention" ); assert_eq!( q.active(), diff --git a/gateway/src/world/loot.rs b/gateway/src/world/loot.rs index 974df98c3..edcb98a51 100644 --- a/gateway/src/world/loot.rs +++ b/gateway/src/world/loot.rs @@ -1,90 +1,4 @@ -//! Realm-wide loot rolls, part of the elastic world-sharding design — voting that survives a -//! participant crossing shards mid-roll. -//! -//! # What moved, and why here -//! -//! `game_loot_roll` / `game_loot_roll_vote` are authoritative on realm-core, alongside -//! `game_group` / `game_group_member` — a roll's audience is who is in the group, not where -//! anyone stands, exactly like the membership it is snapshotted from. Two rejected alternatives are -//! recorded on the issue and not re-litigated here: letting a world shard's MIRROR resolve rolls -//! (makes a cache a decision-maker, the exact property realm-core's group slice removed) and -//! leaving rolls inert to the 60s deadline (a real regression against single-database behaviour). -//! -//! This module is the ROUTING half, the same shape [`super::party`] and [`super::whisper`] are: -//! generic over [`WorldStore`], so the decisions run under test against the in-memory topology those -//! two already built. -//! -//! # Why this is NOT a client-request routing decision alone -//! -//! Party ops and whispers are always triggered by a client opcode, so the gateway can pick which -//! reducer to call right there. A loot roll's CREATION is not: `apply_group_loot_rules` runs deep -//! inside `combat::kill_creature`'s own transaction, on the corpse's own world shard, with no client -//! request to intercept — the module has no path to another database from there. So kill-time -//! creation ALWAYS writes the roll locally first (`module/src/loot.rs::start_roll`, unchanged), and in -//! a sharded deployment that local row is a TRANSIENT staging copy: [`relay_tick`] is what promotes it -//! onto realm-core and clears the shard's copy, so voting and resolution never run twice for the same -//! roll. The `ROLL_START` popup itself needs no relay at all — it already fired locally, from the same -//! transaction that created the staging row, over the per-player relay every recipient already has -//! (they are, by construction, all on this shard the instant the roll starts — see below). -//! -//! Once promoted, a vote is a client action again (`CMSG_LOOT_ROLL`), so [`run_vote`] routes it the -//! same way `party::run` routes its ops. -//! -//! # The new case -//! -//! A roll's participants could not previously be on different shards — `kill_reward_recipients` only -//! ever names members physically near the corpse, i.e. on its own shard, at the instant the roll -//! starts. What this slice makes possible is a participant LEAVING that shard mid-roll (a portal, a -//! dungeon entry) during the 60s window. Before this slice their vote row was simply unreachable -//! from wherever they went and auto-passed at the deadline, same as being offline. Now voting is -//! realm-core state reached through whichever shard the player is CURRENTLY connected to, so a -//! mid-roll shard-hopper can still vote from their new location — [`run_vote`] needs no special -//! case for this, it falls out of routing every vote to realm-core rather than to "the shard that -//! created the roll". -//! -//! # What did NOT move -//! -//! `game_corpse_loot` (which corpse, which slot, item quality) and the item GRANT itself stay on the -//! world shard with their existing escrow guarantees (`items::grant_item`) — only the roll, the -//! group-scoped DECISION, travels. A winner decided on realm-core is handed their item by -//! `settle_loot_roll`, an operator reducer [`relay_tick`] calls on every connected world shard after -//! observing the `ROLL_WON` event realm-core pushed; the module's own `withheld` guard -//! (`settle_roll_grant`'s doc) is what makes a call to the WRONG shard harmless, so the relay never -//! needs to know in advance which one holds the corpse. -//! -//! # What did NOT need to change -//! -//! Disband-mid-roll resolution needed no new plumbing at all: `group::remove_member`'s disband branch -//! already calls `loot::force_resolve_rolls_for_disband` unconditionally, in the same transaction, -//! before it tears the group row down — and that code has no `Plane` branch to skip it. Once the roll -//! rows live on realm-core (via promotion), `remove_member` running there (through `realm_group_op`) -//! resolves them on the spot: same database, same transaction, no mirror, no round trip. -//! -//! # The disband race the periodic relay alone cannot close (found in review) -//! -//! `remove_member`'s guarantee above is only as good as what realm-core's `game_loot_roll` actually -//! HOLDS at the moment it runs. A roll younger than one relay tick is still a local staging copy — -//! `remove_member` running on realm-core cannot see it, force-resolves nothing, and the group row is -//! torn down out from under a roll the relay then promotes onto an ORPHANED group id, which resolves -//! only at the 60s deadline: precisely the fallback the operator's decision rejected, reintroduced in -//! a narrow window. "Someone gets kicked right after a kill" is the ordinary shape of this, not a -//! contrived one — shrinking the poll interval shrinks the window, it does not close it. -//! -//! [`flush_pending_promotions`] closes it: `party::run` calls it, synchronously, immediately before -//! dispatching a LEAVE/UNINVITE — the only two ops that can shrink a group below 2 members and reach -//! `remove_member`'s disband branch. By the time `realm_group_op(LEAVE/UNINVITE, ..)` runs right -//! after it returns, every roll that existed anywhere in the realm at that moment has already landed -//! on realm-core, so `remove_member` sees it. This is a per-op, in-line, cross-database call — not a -//! poll — which is the only way to make the guarantee exact rather than merely "usually fast enough". -//! -//! # Why this relay is a POLL, not `on_insert` callbacks -//! -//! Shared row relays are armed on coordinator connections and explicitly re-armed by the watchdog. -//! `relay_tick` is different because it coordinates writes between every world shard and realm-core, -//! independent of whether any player is connected. A poll reads the current live connection on -//! every tick and naturally carries the cross-database routing context it needs. The 200ms interval -//! bounds ordinary promotion/settlement latency. It plays no role in vote or disband correctness: -//! [`run_vote`] and `party::run` call [`flush_pending_promotions`] synchronously. +//! Realm loot routing. See `docs/realm-loot-routing.md` for the contract and rationale. use anyhow::Result; @@ -175,7 +89,7 @@ fn promote_one(shard: &dyn WorldStore, realm: &dyn WorldStore, roll: &PendingLoo /// calls it immediately before dispatching a LEAVE/UNINVITE — the two ops that can shrink a group /// below 2 members and reach `remove_member`'s disband branch. This is what closes the disband race /// the periodic [`relay_tick`] alone cannot (see this module's doc): by the time -/// `realm_group_op(LEAVE/UNINVITE, ..)` runs right after this returns, every roll that existed +/// `realm_group_op(LEAVE/UNINVITE,..)` runs right after this returns, every roll that existed /// anywhere in the realm at that moment is already on realm-core for `remove_member` to see. /// /// Every connected world shard, not just the acting character's own — a party's members, and diff --git a/gateway/src/world/loot_tests.rs b/gateway/src/world/loot_tests.rs index d7b081153..077b70f8b 100644 --- a/gateway/src/world/loot_tests.rs +++ b/gateway/src/world/loot_tests.rs @@ -296,8 +296,6 @@ fn a_failed_settlement_retries_after_the_shard_recovers() { assert_eq!(*instances.settled_rolls.lock().unwrap(), vec![win]); } -// ---- `flush_pending_promotions` (the disband race, found in adversarial review) ---- - /// **AC: a disband-capable op promotes every connected shard's pending rolls BEFORE it reaches /// realm-core.** Without this, `remove_member`'s disband branch (on realm-core) cannot see a roll /// that is still a local staging copy, and the periodic relay's own 200ms cadence is not fast enough diff --git a/gateway/src/world/mail_tests.rs b/gateway/src/world/mail_tests.rs index c6cda4f9c..77358cc1e 100644 --- a/gateway/src/world/mail_tests.rs +++ b/gateway/src/world/mail_tests.rs @@ -1352,11 +1352,6 @@ fn sharded_seated_sender() -> std::sync::Arc { store } -/// The end-to-end pin for the bug this change fixes: a client sending mail on a sharded realm -/// while the coordinator cache lagged behind `realm_mail_fence` used to get no usable -/// `SMSG_SEND_MAIL_RESULT` at all. The escrow stayed HELD until the sender's next mailbox visit -/// re-drove it. Driven over a real socket through `run_world_session`, not by calling -/// `world::mail::send` directly, so it proves what the CLIENT sees. #[test] fn a_real_session_gets_the_send_mail_result_once_a_lagging_escrow_cache_catches_up() { let store = sharded_seated_sender(); diff --git a/gateway/src/world/member_stats_tests.rs b/gateway/src/world/member_stats_tests.rs index 2f8d315fc..ca23142b9 100644 --- a/gateway/src/world/member_stats_tests.rs +++ b/gateway/src/world/member_stats_tests.rs @@ -589,8 +589,6 @@ fn a_stats_request_is_answered_through_the_encrypted_session() { let _ = server.join(); } -/// A read failure used to end the World Session. Now the first request goes unanswered and the -/// second, for a stranger, still gets its reply on the same socket. #[test] fn a_stats_request_that_cannot_be_read_does_not_end_the_session() { let store = InMemoryStore { diff --git a/gateway/src/world/mod.rs b/gateway/src/world/mod.rs index a3fd10bf8..071ad4e47 100644 --- a/gateway/src/world/mod.rs +++ b/gateway/src/world/mod.rs @@ -333,7 +333,6 @@ pub struct InWorld { /// The guid being melee auto-attacked (combat C1), so `CMSG_ATTACKSTOP` can name it. The /// authoritative engagement lives in `game_melee_attack`; this is protocol state. pub attacking_target: Option, - /// The target whose loot window is open. Targetless take requests use this protocol state. pub open_loot: OpenLootState, /// A ranged auto-repeat (Auto Shot / wand Shoot) is armed. Melee and ranged share one /// `game_melee_attack` row keyed by attacker, so the melee-stop `CMSG_ATTACKSTOP` the client @@ -708,13 +707,6 @@ fn world_handshake_with_queue_and_deadline< ))) } -/// Block until `ticket` is admitted, resending `AUTH_WAIT_QUEUE` (with the current position) on -/// [`QUEUE_RESEND_INTERVAL`] — immediately on the first check, and again sooner than that cadence if -/// the position changes, so the client's queue screen doesn't sit on a stale number. Polls for -/// admission itself on the much shorter [`QUEUE_POLL_INTERVAL`], since that's a cheap mutex lock -/// with no I/O. Returns `Ok(true)` once admitted, `Ok(false)` on a clean disconnect (the write -/// failed — the client hung up while queued, so the caller should `cancel` rather than treat this as -/// session-fatal), `Err` on any other I/O error. fn wait_for_admission( stream: &mut S, encrypt: &mut EncrypterHalf, @@ -904,8 +896,8 @@ fn spawn_writer( ) -> Result> { let trace_enabled = crate::config::writer_trace_enabled(); std::thread::Builder::new() - .name("world-writer".into()) - .spawn(move || { +.name("world-writer".into()) +.spawn(move || { let mut trace = trace_enabled.then(WriterTrace::new); while let Ok(out) = rx.recv() { // The other half of the depth counter (`SessionTx::send` adds): this item is off the @@ -918,10 +910,7 @@ fn spawn_writer( // BEFORE `tx.send`, so an item can only be received after it has been counted, and // this thread is the only decrementer. `depth >= queued` therefore holds always. depth.fetch_sub(1, Ordering::Relaxed); - // A deferred relay job runs HERE, on this session's own writer thread, never - // on the shared coordinator pump that enqueued it. `catch_unwind` because a - // panicking relay body used to cost one session and must keep costing exactly one - // session: the writer stays alive and keeps draining the queue. + let batch: Vec = match out { Outbound::Job(job) => { match std::panic::catch_unwind(std::panic::AssertUnwindSafe(job)) { @@ -996,8 +985,8 @@ fn spawn_writer( } else { let size = (2 + body.len()) as u16; encrypt - .write_encrypted_server_header(&mut wsock, size, opcode) - .and_then(|()| wsock.write_all(&body)) +.write_encrypted_server_header(&mut wsock, size, opcode) +.and_then(|()| wsock.write_all(&body)) } } }; @@ -1012,7 +1001,7 @@ fn spawn_writer( } } }) - .map_err(|e| anyhow!("spawn world writer thread for account {account_id}: {e}")) +.map_err(|e| anyhow!("spawn world writer thread for account {account_id}: {e}")) } /// Drive one world connection, gated by the unlimited [`LoginQueue`] — see @@ -1612,7 +1601,7 @@ fn dispatch( let Some(msg) = handle_mail(tx, store, conn, msg)? else { return Ok(()); }; - // Phase 5/6 (§6): MSG_MOVE_* -> movement_update (persist + relay). The relayed peer events + // (§6): MSG_MOVE_* -> movement_update (persist + relay). The relayed peer events // come back on this player's game_movement_event subscription and are re-emitted (same // opcode + verbatim MovementInfo) to other players by their own subscription callbacks. // @@ -1693,10 +1682,6 @@ fn dispatch_raw_auction_browse( } } -/// Enqueue one already-classified movement packet on the shard's shared batch, then recenter AOI. -/// Called once per packet `CoalesceState` decides to forward: immediately for a state change, later -/// for a coalesced heartbeat. A forwarded packet's on-wire effect is unchanged; only a pure -/// heartbeat's timing can differ. fn forward_movement( store: &St, conn: &mut WorldConn, @@ -1709,26 +1694,6 @@ fn forward_movement( WorldState::InWorld(iw) => iw.self_guid, _ => 0, }; - // A movement packet for an entity that is GONE is not a session-fatal desync — it is the normal - // tail of a cross-map teleport. `teleport_player` despawns the live entity - // the moment the portal's reducer commits, but the client only learns about it when - // `SMSG_TRANSFER_PENDING`/`SMSG_NEW_WORLD` reach it, so every packet already in flight lands - // afterwards — and the wider that window is (a dungeon-entry transaction that spawns a - // 200-creature population is hundreds of milliseconds), the more certain at least one is. With - // this propagating, one stray heartbeat closed the socket WHILE THE CLIENT WAS ON THE LOADING - // SCREEN: no `MSG_MOVE_WORLDPORT_ACK` ever came back, the escrowed transfer that runs on that - // ack never ran, and the player hung forever with no error and no recourse. A hang is the worst - // outcome there is, and a dropped movement packet costs nothing: the client is authoritative for - // its own position and re-sends one every ~100ms (and, mid-port, is about to be rebuilt from the - // durable row anyway). CMSG_ATTACKSWING/ATTACKSTOP keep their session-fatal treatment — those - // desyncs have no self-healing follow-up. - // ...but the tolerance is BOUNDED. "Not in world" is also the answer - // for a desync that will NEVER heal, and movement is the highest-frequency detector we have; an - // unconditional swallow turns a socket that used to close cleanly into a player walking around a - // frozen world forever, never disconnected, with no error — the very outcome `is_desync_error` - // was introduced to prevent. So: drop the port tail, then give up. See MOVE_DESYNC_TOLERANCE. - // The shared batch has no per-entry reducer verdict. The coordinator cache remains the - // authoritative desync signal before enqueueing. if !store.entity_in_world(self_guid) { conn.move_desync_drops += 1; if conn.move_desync_drops > MOVE_DESYNC_TOLERANCE { diff --git a/gateway/src/world/packet_lint.rs b/gateway/src/world/packet_lint.rs index 5fc07fdee..80c21f210 100644 --- a/gateway/src/world/packet_lint.rs +++ b/gateway/src/world/packet_lint.rs @@ -39,7 +39,7 @@ pub fn lint_raw(opcode: u16, body: &[u8]) -> Vec<&'static str> { SET_FACTION_STANDING => { // Layout (build_set_faction_standing_raw): count u32, then per faction // reputation_index u32 + standing u32. An index >= 64 addresses past the client's - // fixed rep array — the McBride ERROR #132 crash class. + // fixed rep array, the McBride ERROR crash class. if body.len() >= 4 { let count = u32::from_le_bytes(body[0..4].try_into().unwrap()) as usize; for i in 0..count { diff --git a/gateway/src/world/party.rs b/gateway/src/world/party.rs index 8a5dad9d6..001c49509 100644 --- a/gateway/src/world/party.rs +++ b/gateway/src/world/party.rs @@ -1011,15 +1011,6 @@ pub(crate) fn run( // just left (their membership row is gone by the time we look again, but the members still in it // need their mirrors updated too). let before = realm.group_roster(self_guid)?; - // Found in adversarial review: LEAVE/UNINVITE are the only two ops that can shrink a group - // below 2 members and reach `remove_member`'s disband branch on realm-core — and that branch - // force-resolves live loot rolls, which the periodic loot-roll relay may not have promoted yet. A - // roll staged in the gap between its kill-time creation and its next scheduled promotion is - // invisible to `remove_member` if a disband lands in that gap ("someone gets kicked right after a - // kill"), and the relay then promotes an ORPHANED roll onto a group id that no longer exists, - // which resolves only at the 60s deadline — exactly the fallback the operator's decision - // rejected, reintroduced in a narrow window. Flushing HERE, synchronously, in-line with the - // dispatch below (not on the relay's own timer), closes it: see `loot::flush_pending_promotions`. if matches!(op, Op::Leave | Op::Uninvite(_)) { crate::world::loot::flush_pending_promotions(store, realm.as_ref()); } @@ -1175,10 +1166,6 @@ pub(crate) fn run_bot_invite_intent( } } -/// Run a SERVER-DRIVEN invite with no client behind it — a session-less Character's invite, closing -/// the gap the group slice opened: the module used to write this shard's LOCAL -/// `game_group`/`game_group_member` rows directly, which the next `sync_group_mirror` push wiped -/// because realm-core had never heard of them. /// /// There is no `account_id` here on purpose: a bot has no per-account connection for a reducer to /// authenticate as, on EITHER topology. So this never takes [`run`]'s unsharded arm (which needs diff --git a/gateway/src/world/party_tests.rs b/gateway/src/world/party_tests.rs index ff88051f0..7266107b8 100644 --- a/gateway/src/world/party_tests.rs +++ b/gateway/src/world/party_tests.rs @@ -90,13 +90,8 @@ pub(super) fn character(guid: u64, name: &str) -> codec::CharacterView { } } -/// A live party topology: realm-core (the party authority) plus the two world shards Phase A runs, -/// wired the way the production gateway wires them — every shard's `realm_store()` is the realm -/// handle, and `world_stores()` is every connected world shard (including the asking one, exactly -/// as `Coordinator::all_shards` answers). -/// -/// Ginger is resident on `world`, Vim on `instances` — the SPLIT that the Phase A tracer could not -/// represent and that made a cross-boundary invite fail live (2026-07-25). +/// Realm-core owns the party. Ginger and Vim live on separate World Shards, +/// each connected to the authority and both World Stores. pub(super) fn party_topology_with( mirror_error: Option<&str>, accept_error: Option<&str>, @@ -615,7 +610,7 @@ fn a_cross_shard_invite_and_accept_form_one_party_on_realm_core() { ); assert!( !ops.iter() - .any(|(_, call)| call == "group_invite" || call == "group_accept"), +.any(|(_, call)| call == "group_invite" || call == "group_accept"), "a multi-database gateway must not run the party op on a world shard's own tables — that is \ exactly the shard-local behaviour realm-wide party routing removes. Calls were {ops:?}" ); @@ -686,10 +681,6 @@ fn every_world_shard_mirrors_the_authoritative_roster_after_a_party_op() { } } -/// The mirror is a WRITE-THROUGH cache, so a party that DISBANDS has to be forgotten everywhere — -/// otherwise each shard keeps a party whose members left, and their local reads keep splitting XP -/// with a group that no longer exists. (This is also the live artifact that motivated this slice: an -/// orphaned `game_group` row, leader Ginger, zero members, left on the instances shard.) #[test] fn a_disbanded_party_is_tombstoned_on_every_world_shard() { let (realm, world, instances, _calls) = party_topology(); @@ -710,7 +701,7 @@ fn a_disbanded_party_is_tombstoned_on_every_world_shard() { assert!( shard.mirror.lock().unwrap().is_empty(), "{name} still mirrors a party that realm-core has disbanded — this is the orphaned \ - `game_group` row the live Phase A run left behind, reproduced" + unexpected shard-local `game_group` row" ); } } @@ -1429,17 +1420,7 @@ fn a_playerbot_is_invitable_because_the_online_gate_reads_the_entity_not_the_ses // Somebody has to ANSWER a bot's invite // =========================================================================================== -/// **AC: a bot accepts a pending group invite from a player.** -/// -/// The invite landed correctly and nothing ever answered it (observed live 2026-07-26). On a -/// single-database gateway the module answers in-transaction — `invite_core` fires `on_group_invite` -/// and `brain.rs`'s `playerbots_auto_accept` accepts through it — but moving the invite onto -/// realm-core, where `pkg_playerbots_bot` is empty, makes the hook a no-op there, and the dialog hung -/// until the 2-minute GC. A human therefore could not group with a bot at all, which is the single -/// most useful manual test the bots exist to support. -/// -/// Pinned here as BEHAVIOUR, not as a source scan: after one `/invite Botty` and nothing else, the -/// authority holds a two-member party — and the acting guid on the ACCEPT is the BOT'S OWN. +/// A bot on a World Shard accepts a Realm-core invite with its own Character guid. #[test] fn a_players_invite_to_a_session_less_bot_is_answered_by_the_bot_itself() { use lyracore_shared::group::realm_op; @@ -1472,19 +1453,6 @@ fn a_players_invite_to_a_session_less_bot_is_answered_by_the_bot_itself() { ); } -/// **A REAL PLAYER, ANSWERED FOR — the impersonation this predicate has to refuse.** -/// -/// Found by adversarial review and reproduced here before it was fixed. The two halves -/// of the predicate used to read DIFFERENT databases: the entity check UNIONED every shard, while the -/// session flag came from [`presence`], which is first-hit-wins over `game_character`. So a guid with -/// a stale row on the ASKING shard and its live, logged-in self on another one had its session flag -/// resolved off the stale copy, and the gateway accepted a group invite on a real player's behalf. -/// -/// Not hypothetical, and not a race: `init` seeds character guid 1 ("Tester") into every database it -/// is published to, so on the live three-database stack a player logged in as guid 1 on -/// `lyracore` has an `online = false` row sitting on `lyracore-instances` — and an inviter -/// standing inside a dungeon asks that shard first. The fix is to read the flag on the shard that -/// HOLDS the live entity; the fixture below is exactly that shape. #[test] fn a_stale_character_row_on_another_shard_cannot_make_a_logged_in_player_look_session_less() { /// The seeded `init` character: a row on EVERY database, `online = false` in the seed. @@ -1564,9 +1532,9 @@ fn the_bots_new_membership_is_mirrored_onto_its_own_shard_by_the_same_op() { ); assert_eq!( world - .group_roster_by_id(group_id) - .unwrap() - .map(|r| r.leader_guid), +.group_roster_by_id(group_id) +.unwrap() +.map(|r| r.leader_guid), Some(GINGER), "and the leader in that mirror is the PLAYER: the follow-the-leader pass resolves its anchor \ from `game_group.leader_guid` and never asks whether the leader is a bot" @@ -1679,10 +1647,10 @@ fn the_bot_answers_within_the_invite_op_itself_with_no_second_call() { ); assert!( !calls - .lock() - .unwrap() - .iter() - .any(|(shard, call)| shard != "lyracore-realm" +.lock() +.unwrap() +.iter() +.any(|(shard, call)| shard != "lyracore-realm" && (call == "group_accept" || call == "group_invite")), "the answer must never run on a world shard's own party tables — that would write membership \ the authority does not have. Calls were {:?}", @@ -1730,13 +1698,8 @@ fn every_party_op_reaches_realm_core_in_its_declared_argument_slots() { ); } -/// The END-TO-END pin for the two production CALL SITES this slice adds — driven over a real -/// socket, through `run_world_session`'s own dispatch, not by calling `world::party` directly. /// -/// Deleting either call site is otherwise a mutation every other test in this file survives: -/// `enter_world`'s `party::on_world_entry` (a party frame the arriving player never gets, and an -/// unmirrored shard) and `social`'s `party::run` (a party op that quietly goes back to being -/// shard-local). Both are asserted here as the CLIENT sees them. +/// World entry delivers the Realm roster, and in-world party operations use Realm-core. #[test] fn a_real_session_syncs_its_party_at_login_and_routes_an_invite_to_realm_core() { let (realm, _world, _instances, calls) = party_topology(); @@ -1774,10 +1737,7 @@ fn a_real_session_syncs_its_party_at_login_and_routes_an_invite_to_realm_core() run_world_session(server_end, server_store.as_ref()).unwrap(); }); let (mut c_enc, mut c_dec) = client_handshake(&mut client, "TESTER", K); - // A READ DEADLINE, and it is the point of the test rather than hygiene: the mutation this pins - // (deleting `party::on_world_entry`) makes the party frame never arrive, and a blocking read on - // a packet that will never come turns a test that must go RED into one that HANGS — which reads - // as neither a pass nor a fail (`no_hang`'s lesson, applied at the socket instead of the thread). + // Missing roster delivery must fail at the read deadline. CMSG_PLAYER_LOGIN { guid: Guid::new(GINGER), } @@ -1810,12 +1770,7 @@ fn a_real_session_syncs_its_party_at_login_and_routes_an_invite_to_realm_core() // …and EVERY party op typed in-world goes to realm-core, not to this shard's own tables — each // one attributed to the character this socket authenticated as. // - // All SEVEN, not just the invite: `realm_group_op` takes the actor's guid as an ARGUMENT, so the - // dispatch's choice of guid IS the authorization for every one of them, and the survivor the - // author found (`0` instead of the session's guid) is a mutation each arm admits independently. - // Pinning only the invite leaves the others free to be attributed to anybody — verified by - // mutation: passing the KICKED player's guid as the actor of `CMSG_GROUP_UNINVITE` left all 408 - // tests green. + // Each operation must carry the Character guid authenticated by this socket. use wow_world_messages::vanilla::{ CMSG_GROUP_ACCEPT, CMSG_GROUP_DECLINE, CMSG_GROUP_DISBAND, CMSG_GROUP_INVITE, CMSG_GROUP_RAID_CONVERT, CMSG_GROUP_UNINVITE, CMSG_LOOT_METHOD, @@ -1878,15 +1833,8 @@ fn a_real_session_syncs_its_party_at_login_and_routes_an_invite_to_realm_core() "the invite ran against the session shard's own party tables — the shard-local behaviour \ realm-wide party routing removes. Calls were {log:?}" ); - // …AS the character this socket authenticated into the world with. `realm_group_op` takes the - // actor's guid as an ARGUMENT (realm-core has no live entity to derive it from), so the guid the - // dispatch threads in IS the authorization. A mutation that passed 0 — or any other player's - // guid — invited on behalf of somebody else with every other assertion here still green. - // - // Every op, with its argument slots, exactly as the dispatch sent it. The AUTHORITY refuses most - // of these (Ginger has no pending invite, and is no longer in a party after the disband) — the - // mock records the tuple before it judges it, which is the point: what is pinned here is what the - // GATEWAY claimed, not what realm-core decided to do about it. + // Realm-core derives authorization from the actor argument. Verify each operation + // carries the authenticated Character and the correct argument slots, including Refusals. use lyracore_shared::group::realm_op; assert_eq!( realm.party.lock().unwrap().ops.clone(), @@ -2265,7 +2213,7 @@ fn an_unsharded_deployment_still_routes_a_bot_invite_through_realm_group_op() { let log = calls.lock().unwrap().clone(); assert!( log.iter() - .any(|(shard, call)| shard == "world" && call == "realm_group_op"), +.any(|(shard, call)| shard == "world" && call == "realm_group_op"), "an unsharded deployment must still use the guid-based realm_group_op — a bot has no account \ connection for `run`'s unsharded arm to call the player-facing reducers as. Calls were {log:?}" ); @@ -2637,12 +2585,6 @@ fn a_raid_joiner_past_a_full_first_subgroup_shows_subgroup_one_in_every_list() { } /// **AC: a member on another shard renders online in a LIST pushed from Realm-core.** -/// -/// Realm-core has no live entities. The roster payload used to carry an online flag the Module -/// computed there anyway, which was 0 for every member, and the relay trusted it: on a sharded -/// Realm every party op re-rendered every member offline until the next world entry. The payload -/// now carries no presence, and the relay reads presence from the World Shard caches. This drives -/// the relay's own decode body, `group_event_outbound`, with the Realm-core handle. #[test] fn the_realm_core_list_relay_renders_a_member_on_another_shard_online() { let (realm, world, instances, _calls) = party_topology(); diff --git a/gateway/src/world/presence.rs b/gateway/src/world/presence.rs index cfe95a7ef..ffea5d8db 100644 --- a/gateway/src/world/presence.rs +++ b/gateway/src/world/presence.rs @@ -1,7 +1,3 @@ -//! Realm Presence — the Gateway's realm-wide read of one Character: in world, session online, -//! race, class, level, zone and Away Status, from whichever World Shard holds it. Guild rosters, -//! friends, `/who`, whisper and Member Stats all read it here, instead of each resolving its own -//! realm-wide Character read the way `party.rs` and `handlers/member_stats.rs` used to. //! //! Existence alone (a durable `game_character` row) never needs proof of absence: it is a positive //! signal, read best-effort from whichever connected Shard answers first. A NEGATIVE claim — diff --git a/gateway/src/world/shard_routing_tests.rs b/gateway/src/world/shard_routing_tests.rs index 000c26d40..84026a3c4 100644 --- a/gateway/src/world/shard_routing_tests.rs +++ b/gateway/src/world/shard_routing_tests.rs @@ -1,14 +1,9 @@ -//! Multi-shard routing — the routing half of Phase A of the elastic-sharding spec. -//! -//! A child module of `world::tests` for the same reason as its siblings: it reaches -//! `InMemoryStore` and its fake realm-core topology without widening anything. `ShardCallLog` and -//! `sharded_stores` are `pub(super)` — the cross-database transfer tests (`transfer_tests`) reuse -//! both, the same way `loot_tests` reuses `party_tests`'s fixtures. +//! Multi-shard routing tests through the shared InMemoryStore topology. use super::*; // =========================================================================================== -// Multi-shard routing — the routing half of Phase A of the elastic-sharding spec. +// Cross-database Transfer and Shard routing // Requirement: reducer calls and subscriptions never target a shard other than the player's home // shard. The `InMemoryStore` pair below stands for two DATABASES sharing one ordered call log, so // a test can read off exactly which database served every player-scoped call of a whole live @@ -483,12 +478,6 @@ fn a_spurious_worldport_ack_is_ignored_on_the_default_shard() { #[test] fn a_logout_to_character_select_releases_the_home_shard_pin() { - // Adversarial-review finding: `leave_world` returns the socket to CharSelect but the session - // stays open, so the NEXT character-select frames (char enum / create / delete) are dispatched - // through `on_home_shard!` again. Those are REALM-scoped — `game_account` / `game_character` - // live on the default database — so a pin left over from the character we just logged out of - // would serve the character list off an instance shard (which, being empty, shows the player - // no characters at all, and would create/delete rows on the wrong database). let (store, calls) = sharded_stores(); let (mut client, server_end) = world_session_socket_pair(); let server_store = store.clone(); diff --git a/gateway/src/world/social.rs b/gateway/src/world/social.rs index 82abfb449..daa43fce1 100644 --- a/gateway/src/world/social.rs +++ b/gateway/src/world/social.rs @@ -440,8 +440,7 @@ fn target_icon_op(update: &MSG_RAID_TARGET_UPDATE_Client) -> party::Op { /// /// `pub(super)` because every Realm Chat Line needs the same guid for the same reason: /// `realm_chat` and `realm_whisper` take the speaker as an argument, so this accessor is the -/// authorization of every line (see `handlers::chat`). One accessor, so there is one place a -/// mutation can substitute a literal, and one place the end-to-end tests pin. +/// authorization of every line (see `handlers::chat`). pub(super) fn self_guid(conn: &WorldConn) -> Option { match &conn.state { WorldState::InWorld(iw) => Some(iw.self_guid), diff --git a/gateway/src/world/store.rs b/gateway/src/world/store.rs index 49396211f..ac13e869d 100644 --- a/gateway/src/world/store.rs +++ b/gateway/src/world/store.rs @@ -519,7 +519,6 @@ pub trait WorldStore: Ok(None) } - /// One healthy, internally consistent cache observation used to certify a party partition. fn party_holder_observation( &self, character_guid: u64, @@ -535,8 +534,6 @@ pub trait WorldStore: }) } - /// Authoritative roster read used to repair mirrors after deleted Character cleanup. A - /// configured but unhealthy Realm-core must return `Err`, never a false disband. fn party_cleanup_group_roster_by_id( &self, group_id: u64, @@ -644,7 +641,7 @@ pub trait WorldStore: Ok((after_id, Vec::new())) } - /// The account's characters for the character-select screen (Phase 3). In production this + /// The account's characters for the character-select screen. In production this /// reads the per-player `game_character` subscription (RLS-restricted to the owner). fn characters(&self, account_id: u64) -> Result>; @@ -669,7 +666,7 @@ pub trait WorldStore: character_guid: u64, ) -> Result; - /// Enter the world with `character_guid` (Phase 4): calls the `player_login` reducer and + /// Enter the world with `character_guid`: calls the `player_login` reducer and /// returns the live entity to spawn (from the resulting `game_world_entity` row). Errors if /// the character isn't the caller's. `entry` picks the reducer: a world-port keeps the Away /// Status, a fresh login ends it. @@ -693,7 +690,7 @@ pub trait WorldStore: /// Subscribe this player's connection to its per-player views (nearby `game_world_entity`, /// addressed `game_movement_event`) and push the resulting peer-spawn / movement-relay / destroy - /// SMSG onto `tx` (Phase 6/7). The returned guard tears the subscription + callbacks down on + /// SMSG onto `tx`. The returned guard tears the subscription + callbacks down on /// drop. Called once, at `CMSG_PLAYER_LOGIN`, when `self_guid` is known. /// /// `arrival` is the entity the login batch was just built from, so the viewer's map, partition, @@ -877,7 +874,7 @@ pub trait WorldStore: fn gossip_options(&self, npc_guid: u64) -> Result>; /// Respec at `trainer_guid` (the "I wish to unlearn my talents." gossip option, gated to level - /// 10+ by `filtered_gossip_options` — #516). Errors (out of range / not enough gold) are + /// 10+ by `filtered_gossip_options`). Errors (out of range / not enough gold) are /// per-action; the caller just closes the gossip window either way. fn reset_talents(&self, account_id: u64, self_guid: u64, trainer_guid: u64) -> Result<()>; @@ -943,7 +940,7 @@ pub trait WorldStore: /// Draw or stow the player's weapons (`CMSG_SETSHEATHED`, the `Z` key). `state` is 0 stowed / /// 1 melee / 2 ranged; the module range-checks it. Writes `UNIT_FIELD_BYTES_2` byte 0, which is - /// what makes a drawn or stowed weapon visible to OTHER players. [#101] + /// what makes a drawn or stowed weapon visible to OTHER players. fn set_sheathed(&self, account_id: u64, self_guid: u64, state: u8) -> Result<()>; /// The live entity's max health (0 if not in world) — the fall-damage flavor line folds @@ -1309,7 +1306,7 @@ pub trait WorldStore: /// activated healer's guid (passed through to the confirm echo). The module gates on ghost state. fn spirit_healer_res(&self, account_id: u64, self_guid: u64, healer_guid: u64) -> Result<()>; - // --- Trade (#120). Every status — BeginTrade/OpenWindow to the parties, or a refusal back to + // --- Trade. Every status, BeginTrade/OpenWindow to the parties, or a refusal back to // the caller — rides the `game_trade_event` relay; these calls answer nothing synchronously, // and an `Err` is only an unresolved actor (per-action, log + ignore). @@ -1320,7 +1317,7 @@ pub trait WorldStore: /// `CMSG_CANCEL_TRADE` — tear the caller's Trade Session down (`TradeCanceled` to both). fn cancel_trade(&self, account_id: u64, self_guid: u64) -> Result<()>; /// `CMSG_SET_TRADE_ITEM` — `inv_slot` is the ABSOLUTE inventory slot (the gateway maps the - /// client's (bag, slot) pair, the item-family convention) (#121). + /// client's (bag, slot) pair, the item-family convention). fn set_trade_item( &self, account_id: u64, @@ -1328,18 +1325,18 @@ pub trait WorldStore: trade_slot: u8, inv_slot: u8, ) -> Result<()>; - /// `CMSG_CLEAR_TRADE_ITEM` (#121). + /// `CMSG_CLEAR_TRADE_ITEM`. fn clear_trade_item(&self, account_id: u64, self_guid: u64, trade_slot: u8) -> Result<()>; - /// `CMSG_SET_TRADE_GOLD` — `copper` is the offered amount (#121). + /// `CMSG_SET_TRADE_GOLD`, `copper` is the offered amount. fn set_trade_gold(&self, account_id: u64, self_guid: u64, copper: u32) -> Result<()>; /// `CMSG_ACCEPT_TRADE` — accept the current offer; dual-accept runs the atomic Trade Commit - /// module-side (#122). + /// module-side. fn accept_trade(&self, account_id: u64, self_guid: u64) -> Result<()>; - /// `CMSG_UNACCEPT_TRADE` — withdraw an accept; partner hears `BackToTrade` (#122). + /// `CMSG_UNACCEPT_TRADE`, withdraw an accept; partner hears `BackToTrade`. fn unaccept_trade(&self, account_id: u64, self_guid: u64) -> Result<()>; - /// `CMSG_BUSY_TRADE` — decline a pending proposal as busy; initiator hears `Busy` (#123). + /// `CMSG_BUSY_TRADE`, decline a pending proposal as busy; initiator hears `Busy`. fn busy_trade(&self, account_id: u64, self_guid: u64) -> Result<()>; - /// `CMSG_IGNORE_TRADE` — decline via ignore; initiator hears `IgnoreYou` (#123). + /// `CMSG_IGNORE_TRADE`, decline via ignore; initiator hears `IgnoreYou`. fn ignore_trade(&self, account_id: u64, self_guid: u64) -> Result<()>; /// Find `owner_guid`'s corpse location `(map_id, x, y, z)` for `MSG_CORPSE_QUERY` (slice 5). diff --git a/gateway/src/world/tests.rs b/gateway/src/world/tests.rs index 4f5aa1ed4..12a43d4ef 100644 --- a/gateway/src/world/tests.rs +++ b/gateway/src/world/tests.rs @@ -162,11 +162,7 @@ mod alpha_test_tools_tests; mod shard_routing_tests; use shard_routing_tests::ShardCallLog; -/// Cross-database transfer — Phase A of the elastic world-sharding design (escrowed transfers, -/// instance/continent shards, region-level load balancing). A sibling of the -/// modules above for the same reason; its `FakeShardDb`/`FakeChar`/`lk` fixtures stay in THIS file -/// (see the comment above `struct FakeChar` below) because this file's own `Store` impl and two -/// earlier regression tests construct them directly. +/// Cross-database Transfer tests share the Character and Escrow Fakes defined here. #[path = "transfer_tests.rs"] mod transfer_tests; @@ -393,11 +389,7 @@ struct InMemoryStore { deleted: std::sync::Mutex>, /// When set, `delete_character` returns this outcome instead of `Success`. delete_outcome: Option, - /// Characters actually produced by a `create_character` call during the test, unioned - /// into `characters()`'s answer. Without this, a CMSG_CHAR_CREATE round trip is a no-op the - /// fake immediately forgets, so a test driving CREATE then CMSG_CHAR_ENUM/CMSG_PLAYER_LOGIN - /// for "the character just created" would actually be exercising a hardcoded/pre-seeded guid - /// with no real link to the CREATE call — the tautology a review caught. + created_characters: std::sync::Mutex>, /// The Nth guid `create_character` assigns, offset well above every hand-seeded fixture /// guid in this file (the highest is 100, in the transfer tests) so it can never collide. @@ -464,7 +456,7 @@ struct InMemoryStore { player_items_fixture: Vec, /// Item-instance guid → bag slot, for the vendor repair target. item_slots: Vec<(u64, u8)>, - /// Recorded `set_sheathed` dispatches: (self_guid, state) — the `CMSG_SETSHEATHED` route (#101). + /// Recorded `set_sheathed` dispatches: (self_guid, state), the `CMSG_SETSHEATHED` route. sheathed: std::sync::Mutex>, /// What `channel_op` answers. `None` succeeds. channel_outcome: Option, @@ -502,7 +494,7 @@ struct InMemoryStore { /// Whether `bind_home` ran (the innkeeper gossip select). home_bound: std::sync::atomic::AtomicBool, /// Recorded `reset_talents` dispatches: (account_id, self_guid, trainer_guid) — the unlearn-talents - /// gossip select (#516). + /// gossip select. reset_talents_calls: std::sync::Mutex>, /// When set, `reset_talents` returns this error instead of recording the call. reset_talents_error: Option, @@ -541,7 +533,7 @@ struct InMemoryStore { gameobject_type: Option, /// Recorded non-questgiver GameObject uses owned by the loot-window seam. gameobjects_used: std::sync::Mutex>, - /// Recorded generated-loot reads, used to prove questgivers bypass the chest lifecycle. + corpse_loot_reads: std::sync::Mutex>, /// Recorded `group_loot_method` calls: (loot_setting, master_guid, loot_threshold). group_loot_methods: std::sync::Mutex>, @@ -882,26 +874,26 @@ struct InMemoryStore { /// This shard's bags have no room — the fixture behind the full-bag refusal on a take. /// Atomic so a test can flip it AFTER the fixture is wrapped in an `Arc`, like `purses`. bags_full: std::sync::atomic::AtomicBool, - /// Recorded `initiate_trade` calls — `(self_guid, target_guid)` off CMSG_INITIATE_TRADE (#120). + /// Recorded `initiate_trade` calls, `(self_guid, target_guid)` off CMSG_INITIATE_TRADE. initiated_trades: std::sync::Mutex>, - /// Recorded `begin_trade` self_guids — CMSG_BEGIN_TRADE (#120). + /// Recorded `begin_trade` self_guids, CMSG_BEGIN_TRADE. begun_trades: std::sync::Mutex>, - /// Recorded `cancel_trade` self_guids — CMSG_CANCEL_TRADE (#120). + /// Recorded `cancel_trade` self_guids, CMSG_CANCEL_TRADE. cancelled_trades: std::sync::Mutex>, /// Recorded `set_trade_item` calls — `(self_guid, trade_slot, inv_slot)` AFTER the gateway's - /// (bag, slot) → absolute-slot mapping (#121). + /// (bag, slot) → absolute-slot mapping. set_trade_items: std::sync::Mutex>, - /// Recorded `clear_trade_item` calls — `(self_guid, trade_slot)` (#121). + /// Recorded `clear_trade_item` calls, `(self_guid, trade_slot)`. cleared_trade_items: std::sync::Mutex>, - /// Recorded `set_trade_gold` calls — `(self_guid, copper)` after the wire's Gold decode (#121). + /// Recorded `set_trade_gold` calls, `(self_guid, copper)` after the wire's Gold decode. set_trade_golds: std::sync::Mutex>, - /// Recorded `accept_trade` self_guids — CMSG_ACCEPT_TRADE (#122). + /// Recorded `accept_trade` self_guids, CMSG_ACCEPT_TRADE. accepted_trades: std::sync::Mutex>, - /// Recorded `unaccept_trade` self_guids — CMSG_UNACCEPT_TRADE (#122). + /// Recorded `unaccept_trade` self_guids, CMSG_UNACCEPT_TRADE. unaccepted_trades: std::sync::Mutex>, - /// Recorded `busy_trade` self_guids — CMSG_BUSY_TRADE (#123). + /// Recorded `busy_trade` self_guids, CMSG_BUSY_TRADE. busy_trades: std::sync::Mutex>, - /// Recorded `ignore_trade` self_guids — CMSG_IGNORE_TRADE (#123). + /// Recorded `ignore_trade` self_guids, CMSG_IGNORE_TRADE. ignore_trades: std::sync::Mutex>, /// Live `game_world_entity` rows on THIS shard, as the columns Member Stats read. member_entities: std::sync::Mutex>, @@ -1162,8 +1154,7 @@ impl WorldStore for InMemoryStore { &self.shard } - // --- The escrow protocol, with the MODULE's guards reproduced. A permissive mock would - // --- let every ordering mutation in `run_transfer` pass; these are what make the order matter. + // The Fake enforces the Module's escrow guards so transfer ordering affects outcomes. fn settle_home_shard( &self, @@ -1312,10 +1303,7 @@ impl WorldStore for InMemoryStore { return Err(anyhow!("bot Transfer arrival identity is invalid")); } } - // NOTE: every `in_rows` guard below is scoped and dropped before `db.live()`, which locks - // `in_rows` itself. `std::sync::Mutex` is not re-entrant, so holding one across that call - // self-deadlocks — and a deadlock makes an ordering mutation HANG the suite instead of - // turning a named test red, which is a coverage failure wearing a hang's clothes. + // Drop each `in_rows` guard before `db.live()`, which locks the same non-reentrant Mutex. let replayed = lk(&db.in_rows).get(&transfer_id).copied(); if let Some(existing) = replayed { if existing != guid { @@ -1916,11 +1904,6 @@ impl WorldStore for InMemoryStore { _gender: u8, _appearance: codec::Appearance, ) -> Result { - // Fake: a name already among the seeded OR previously created characters is "in use", - // else success — and a success actually RECORDS the character, assigning it a real - // guid `characters()` then unions in. Before a review caught it, this call was a pure - // no-op the fake immediately forgot, which let a test claim to drive "the character - // CREATE just produced" while actually logging into an unrelated hardcoded/pre-seeded guid. if self.characters.iter().any(|c| c.name == name) || self .created_characters @@ -3263,7 +3246,7 @@ impl WorldStore for InMemoryStore { self.rec("group_accept"); Ok(PartyOutcome::Ran) } - // Trade (#120): pure recorders — the module owns every gate, so the fake just proves which + // Trade : pure recorders, the module owns every gate, so the fake just proves which // verb the dispatch chose and which args survived the wire. fn initiate_trade(&self, _account_id: u64, self_guid: u64, target_guid: u64) -> Result<()> { self.rec("initiate_trade"); @@ -5858,9 +5841,6 @@ fn a_member_enters_the_world_with_its_guild_on_the_self_create_and_signs_on() { assert!(recorded(&store).contains(&"guild_op:SignOn".to_string())); } -/// Every membership opcode this ticket adds reaches `realm_guild_op`, over a real encrypted -/// socket. Character 1 leads Guild 7; Character 2 ("Target") is a live realm-wide candidate for -/// CMSG_GUILD_INVITE. #[test] fn every_membership_opcode_reaches_its_durable_request() { let store = std::sync::Arc::new(InMemoryStore { @@ -6910,10 +6890,6 @@ fn worldport_ack_reenters_with_fresh_subscription_and_empty_loot_state() { let _ = open_loot_window(&mut client, &mut c_enc, &mut c_dec, 60); - // The client finished "loading" the new map (having received TRANSFER_PENDING/NEW_WORLD from the - // on_teleport relay, which this InMemoryStore-driven dispatch test doesn't exercise — that's - // covered by `stdb::subscriptions::tests::teleport_relay_*` and the codec pins) -> sends the - // (empty-body) ack. MSG_MOVE_WORLDPORT_ACK {} .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); @@ -7041,7 +7017,7 @@ fn worldport_removes_the_source_viewer_before_routing_and_registers_a_replacemen fn login_initialize_factions_carries_persisted_standing_at_its_reputation_index() { // A persisted `game_player_reputation` row must land in the login // SMSG_INITIALIZE_FACTIONS at its STORED reputation_index slot (0..63), never faction_id — the - // guardrail that also gates the live SET_FACTION_STANDING relay (McBride ERROR #132). + // guardrail that also gates the live SET_FACTION_STANDING relay (McBride ERROR). let store = std::sync::Arc::new(InMemoryStore { login_entity: Some(warrior_entity()), // Stormwind's rep-index is 19 (Faction.dbc ReputationListID), NOT its faction id (72) — @@ -7303,11 +7279,11 @@ fn a_movement_packet_for_a_despawned_entity_never_kills_the_session() { ); assert_eq!( calls - .lock() - .unwrap() - .iter() - .filter(|(_, c)| c == "movement_update") - .count(), +.lock() +.unwrap() +.iter() +.filter(|(_, c)| c == "movement_update") +.count(), 1, "the transfer-tail packet must not enter the batch, while movement resumes when presence returns" ); @@ -8698,10 +8674,6 @@ fn quest_choose_reward_relays_inventory_before_completion_over_the_cipher() { ); } -// Abandon-slot resolution and the raw descriptor build are now unit-tested at the quest seam -// (`handlers/quest.rs`, `InMemoryQuestActions`) — that's where `dispatch_quest_action` and -// `quest_log_update` actually live. This test proves the one thing the seam test cannot: that a -// real login wires `send_quest_log` to that same descriptor, sent after the self CREATE. #[test] fn login_sends_the_quest_log_descriptor_raw_update_after_the_create_packet() { let slots = vec![codec::update_mask::QuestLogSlot { @@ -9576,9 +9548,6 @@ fn instant_cast_sends_start_then_raw_cast_result_ok_then_go_and_threads_the_targ #[test] fn set_sheathed_routes_the_clients_z_press_to_the_store() { - // #101: CMSG_SETSHEATHED used to reach NO handler — it fell through every arm of `dispatch` and - // was dropped, so UNIT_FIELD_BYTES_2 stayed 0 forever and peers rendered everyone unarmed. - // Each of the three real states must reach the store verb with its byte intact. for (sent, expect) in [ (SheathState::Unarmed, 0u8), (SheathState::Melee, 1), @@ -11510,7 +11479,7 @@ fn quest_store_at_level(level: u8) -> InMemoryStore { #[test] fn gossip_hello_hides_unlearn_talents_below_level_10() { - // #516: the imported "I wish to unlearn my talents." row (reclassified by the importer to + // the imported "I wish to unlearn my talents." row (reclassified by the importer to // `UNLEARNTALENTS`, since the raw dump column never carries it) must not render for a character // who cannot yet have a talent point. use lyracore_shared::constants::gossip_option; @@ -11768,9 +11737,6 @@ fn messagechat_emote_routes_to_chat_type_3() { #[test] fn messagechat_dot_say_diverts_to_gm_command_never_touching_chat() { - // A Say line starting with '.' diverts to gm_command BEFORE send_chat — never a - // broadcast, never a game_chat_event insert. No reply on success (the command's own effect is its - // own feedback). let store = std::sync::Arc::new(quest_store()); let (mut client, mut c_enc, mut c_dec, server) = enter_world(store.clone(), 1); CMSG_MESSAGECHAT { @@ -12372,26 +12338,11 @@ struct FakeEscrow { blob: Vec, } -/// EVERY lock on a `FakeShardDb` goes through here, and it is `try_lock`, not `lock`. -/// -/// The fake is only ever touched from the test's own thread, so a failed `try_lock` can only mean -/// one thing: one of its own methods is holding that mutex further up the stack. `std::sync::Mutex` -/// is not re-entrant, so the real `lock()` blocks forever — and `cargo test` has no per-test -/// timeout, so the suite HANGS instead of failing. A review of the cross-database transfer driver -/// hit exactly that: an ordering mutation of the driver made the gateway suite hang rather than -/// turn a named test red, which is a coverage failure wearing a pass's clothes — mutation testing -/// exists to catch exactly this. A hang must never be a pass. -/// -/// Deliberate simplification: `try_lock` instead of a watchdog thread per lock — it is one line, -/// it fires instantly, and it names the offending mutex in the panic. The ceiling: it would also -/// fire on genuine cross-thread contention, which these tests do not have (one `FakeShardDb` per -/// test, one thread per test). `transfer_tests::no_hang` is the belt-and-braces net for a hang that -/// is NOT a re-entrant lock (an unbounded retry loop in the driver, say). fn lk(m: &std::sync::Mutex) -> std::sync::MutexGuard<'_, T> { m.try_lock().expect( "re-entrant lock on FakeShardDb: a method is already holding this mutex further up the \ stack. With `lock()` this would be a DEADLOCK and the suite would HANG instead of failing \ - — see the fn doc on `lk`.", + — see the fn doc on `lk`.", ) } diff --git a/gateway/src/world/trade_tests.rs b/gateway/src/world/trade_tests.rs index c5367a9af..06b5adf6a 100644 --- a/gateway/src/world/trade_tests.rs +++ b/gateway/src/world/trade_tests.rs @@ -1,10 +1,4 @@ -//! Trade handshake dispatch (#120) — the wire E2E half of the ticket's acceptance: real cipher, -//! real packets, the `InMemoryStore` standing in for the module. What EXECUTES is the dispatch -//! chain and `handlers::trade`; what the recorders prove is which store verb each CMSG chose and -//! which arguments survived the wire, from BOTH sides of the trade. The other half — trade-event -//! rows decoding to `SMSG_TRADE_STATUS` on the recipient's socket — is pinned by -//! `stdb::subscriptions::tests::trade_event_kinds_decode_to_their_trade_status_variants` plus the -//! shared `private_recipient_audience` tests; the full push is the dev-smoke/live-client pass. +//! Trade dispatch through a Headless Client and the existing Store Fake. use super::*; use wow_world_messages::vanilla::{ @@ -78,7 +72,7 @@ fn the_handshake_flow_dispatches_initiate_then_begin_then_cancel() { assert_eq!(calls, ["initiate_trade", "begin_trade", "cancel_trade"]); } -/// **AC (#121): offer mutations dispatch with the wire's arguments** — set item (main bag → +/// **AC : offer mutations dispatch with the wire's arguments**, set item (main bag → /// absolute slot passthrough), clear item, and gold (the `Gold` wire type decoded back to /// copper). One client, three opcodes, three recorders. #[test] @@ -116,7 +110,7 @@ fn offer_mutations_dispatch_with_wire_arguments() { ); } -/// **AC (#121): sub-bag items are out of scope, not mis-addressed** — a `CMSG_SET_TRADE_ITEM` +/// **AC : sub-bag items are out of scope, not mis-addressed**, a `CMSG_SET_TRADE_ITEM` /// from an equipped sub-bag is logged and IGNORED (the item-action dispatcher posture), never /// forwarded /// with a bag-local slot number that would alias a main-bag slot. @@ -136,7 +130,7 @@ fn set_trade_item_from_a_sub_bag_is_ignored_not_misaddressed() { assert!(store.set_trade_items.lock().unwrap().is_empty()); } -/// **AC (#122): the full loop's wire half** — A initiates and offers an item, B answers, offers +/// **AC : the full loop's wire half**, A initiates and offers an item, B answers, offers /// gold, and both accept; every opcode dispatches its verb as the right seat, in order. (The /// swap itself — items, gold, atomicity — is the module's pure-tested commit core; the fake /// store records the dispatch, per the settled seam.) @@ -188,7 +182,7 @@ fn the_full_loop_dispatches_offer_and_dual_accept_in_order() { assert_eq!(store.accepted_trades.lock().unwrap().as_slice(), &[1, 2]); } -/// **AC (#122): the accept-reset wire half** — after an accept, a further offer mutation and an +/// **AC : the accept-reset wire half**, after an accept, a further offer mutation and an /// explicit unaccept both dispatch; the reset itself (both flags cleared, BackToTrade to both) /// is the module's pure-tested rule. #[test] @@ -213,7 +207,7 @@ fn unaccept_and_post_accept_mutations_dispatch_for_the_acting_seat() { assert_eq!(store.unaccepted_trades.lock().unwrap().as_slice(), &[1]); } -/// **AC (#123): the decline flow** — the proposed target's client answers a `BeginTrade` it +/// **AC : the decline flow**, the proposed target's client answers a `BeginTrade` it /// can't take with `CMSG_BUSY_TRADE` (already in a dialog) or `CMSG_IGNORE_TRADE` (initiator /// ignored); each dispatches its own decline verb as the declining side. #[test] diff --git a/gateway/src/world/transfer.rs b/gateway/src/world/transfer.rs index 080bc032b..07de5fb50 100644 --- a/gateway/src/world/transfer.rs +++ b/gateway/src/world/transfer.rs @@ -1,40 +1,4 @@ -//! The gateway's half of the escrowed cross-database transfer — the Phase A tracer (enter Deadmines -//! on the instances shard and come back), part of the elastic world-sharding design. -//! -//! The module owns the state machine (`module/src/transfer/`); this file owns the ORDER the two -//! databases are driven in, because that ordering is the one safety property neither database can -//! check for itself — each can only see its own ledger row. -//! -//! ```text -//! SOURCE shard DESTINATION shard -//! 1 begin_transfer ── freeze + serialize ──► (nothing yet) -//! 2 ensure_instance (mirror the dungeon) -//! 3 import_character_blob (rows land, fenced) -//! 4 confirm_import ◄── "it committed" ────── -//! 5 finish_transfer ── source copy destroyed -//! 6 publish_shard_index ── realm routing ready -//! 7 sync_transfer_arrival (party mirror ready) -//! 8 release_transfer (arrival goes LIVE) -//! 9 evict_instance_population (the world writer stops ticking the dungeon) -//! ``` -//! -//! **Every step is idempotent**, which is what makes a killed gateway recoverable without any -//! gateway-side state: the escrow row on the source carries the transfer id, the destination and the -//! blob, so a fresh process re-derives the whole plan from durable data and re-runs the sequence -//! from the top. Replayed steps are no-ops (`BeginPlan::Replay`, the in-row PK, `FinishPlan:: -//! AlreadyDone`, `release_transfer`'s missing-row arm). -//! -//! **The transfer id IS the character guid.** A character can hold at most one escrow at a time -//! (`BeginPlan::AlreadyInTransit` refuses a second), and guid 0 does not exist — so using the guid -//! makes the id re-derivable by a gateway that restarted with no memory of what it was doing. That -//! is the entire recovery mechanism: nothing about an in-flight transfer lives in gateway RAM. -//! -//! Deliberate simplification: the Escrow drive is synchronous. For a player it runs on the world -//! session's own thread, inside the client's loading screen (the WORLDPORT_ACK handler); ceiling: a -//! slow or unreachable destination shard stalls that one session for the reducer timeout. -//! -//! A Character with no Session is driven by one bounded Gateway dispatcher per World Shard. A slow -//! destination holds that dispatcher's worker while other Gateway and SDK threads continue. +//! The Gateway drives escrowed cross-database Transfers and resumes interrupted crossings. use anyhow::{anyhow, Result}; diff --git a/gateway/src/world/transfer_tests.rs b/gateway/src/world/transfer_tests.rs index 892584bcb..b51bcd5a9 100644 --- a/gateway/src/world/transfer_tests.rs +++ b/gateway/src/world/transfer_tests.rs @@ -1,20 +1,10 @@ -//! Cross-database transfer — Phase A of the elastic-sharding spec. -//! -//! A child module of `world::tests` for the same reason as its siblings — it reaches -//! `InMemoryStore` and the fixtures below without widening anything. Unlike the other extracted -//! sections, the fixture TYPES this section drives (`FakeChar`, `FakeEscrow`, `FakeShardDb`, -//! `fake_blob`/`parse_blob`, and the generic `lk` lock helper) stay defined in `tests.rs` itself: -//! tests.rs's own `InMemoryStore` (the `xdb`/`xstep` glue its `Store` impl uses) and two -//! world-port-abort regression tests earlier in that file construct `FakeShardDb`/`FakeChar` -//! directly, so those definitions are a shared fixture, not section-local — see the comment above -//! them in `tests.rs`. `sharded_stores`/`drive_routed_session`/`ShardCallLog` are `shard_routing_tests`'s, -//! reused here the same way `loot_tests` reuses `party_tests`'s fixtures. +//! Cross-database Transfer tests through the shared InMemoryStore topology. use super::shard_routing_tests::{drive_routed_session, sharded_stores, ShardCallLog}; use super::*; // =========================================================================================== -// Cross-database transfer — Phase A of the elastic-sharding spec. +// Cross-database Transfer and Shard routing // // `FakeShardDb` is a faithful re-implementation of the MODULE's escrow guards // (`module/src/transfer/mod.rs`'s `plan_begin`/`plan_import`/`plan_finish` + `release_transfer`'s @@ -23,15 +13,7 @@ use super::*; // Two `FakeShardDb`s stand for two SpacetimeDB databases — the same shape `sharded_stores` // uses for routing. // -// Deliberately NOT a permissive mock: a fake that recorded calls and returned Ok would let every -// ordering mutation pass, which is the exact coverage gap the transfer-primitive and -// in-transit-gate reviews kept finding. -// =========================================================================================== -/// Run a test body under a wall-clock deadline, so a hang is a FAILURE rather than a CI job that -/// sits at "still running" until someone kills it. Used on the cross-database driver tests — the -/// ones that walk two databases through a multi-step protocol and are therefore the only place in -/// this suite where a wedge could be a loop rather than a lock. fn no_hang(secs: u64, f: impl FnOnce() -> T + Send + 'static) -> T { let h = std::thread::spawn(f); // Poll `is_finished` rather than shipping the result through a channel, so a body that PANICS @@ -100,12 +82,6 @@ fn xdb_pair( (src, dst, src_db, dst_db, calls) } -/// A deadlock found in review, turned into a named failure. -/// -/// `FakeShardDb::import_character_blob` used to hold the `in_rows` guard across `db.live()`, which -/// locks `in_rows` again — only the `&&` short-circuit in `has()` kept the happy path alive. When a -/// driver mutation reached that line the gateway suite HUNG instead of turning a test red. Every -/// lock now goes through `lk` (`try_lock`), so the same re-entrancy is an instant, named panic. /// /// This test asserts the property directly: hold a guard, take the same mutex again, and the /// process must come back with a failure rather than never coming back at all. @@ -263,14 +239,6 @@ fn a_completed_transfer_publishes_the_destination_to_the_realm_core_index() { }); } -/// Step 5b publishes the ESCROW OUT-ROW's destination, never the caller's `plan`. -/// -/// This is the clause the whole "a replication, not a stale-index generator" argument rests on — -/// the index can only ever name a destination `finish_transfer` actually settled, because it is read -/// from the same row `do_finish` recorded its own receipt from. Every other clause was executed; -/// this one was not, and substituting `plan.dest_*` for `escrow.dest_*` survived the whole suite -/// (found by adversarial review). The two agree on today's call paths, which is exactly why nothing -/// noticed — and `run_transfer` re-reads the escrow precisely because they are not guaranteed to. #[test] fn a_resumed_transfer_publishes_the_escrow_destination_not_the_callers_plan() { no_hang(30, || { @@ -798,9 +766,6 @@ fn a_normal_resident_does_not_need_transfer_arrival_repair() { #[test] fn a_second_transfer_of_the_same_character_is_never_swallowed_as_a_replay() { - // THE REPEAT-TRANSFER CASE (found by adversarial review). The transfer id IS the character - // guid, so every hop a character ever makes reuses ONE id — and `plan_begin` reads "an out-row - // OR an in-row filed under this id names this character" as `BeginPlan::Replay`, i.e. `Ok(())`. // // Reachable state: the character hopped world -> instances and the driver died between // `finish_transfer` and `release_transfer`, so the instances shard holds the character AND an @@ -919,14 +884,6 @@ fn entering_the_world_binds_this_accounts_identity_on_the_shard_it_landed_on() { ); } -// The escrow-priority tripwire that used to live here (`locate_character_still_prefers_the_shard_ -// holding_the_escrow`, a source scan of `Coordinator::locate_character`) was retired once -// `settle_home_shard`'s holder lookup became `realm_core::locate_home_shard`, generic over the -// `RealmDb` seam, and the escrow-priority property is pinned BEHAVIOURALLY there instead — -// `locate_home_shard_still_prefers_the_shard_holding_the_escrow_in_the_fallback_scan` in -// `realm_core.rs`, which runs the real fallback-scan code against `fake::Handle` rather than -// matching its source text. - #[test] fn a_resumed_transfer_reuses_the_escrowed_destination_not_the_character_row() { // Resume authority: once an escrow exists, ITS destination is the one the destination shard may diff --git a/gateway/src/world/wire_corruption_tests.rs b/gateway/src/world/wire_corruption_tests.rs index ebea0e8ea..97fec4da5 100644 --- a/gateway/src/world/wire_corruption_tests.rs +++ b/gateway/src/world/wire_corruption_tests.rs @@ -308,20 +308,6 @@ fn writer_trace_dump_writes_a_file_with_the_traced_frames() { std::fs::remove_dir(dir).unwrap(); } -/// Hardening (the actual fix): an `Outbound::Raw` body that overflows the u16 frame-size -/// field must end the session instead of silently wrapping the declared size — the wrap is exactly -/// the "one wrong size field desyncs every later header" mechanism this whole investigation chased. -/// No current builder produces a body this large (confirmed by reading every `Outbound::Raw` call -/// site), so this exercises the guard directly rather than waiting for a caller to reach it. -/// -/// Mutation-checked by hand: reverting this arm to the old `debug_assert!`-only form makes this test -/// FAIL — in `cargo test`'s own debug profile, the reinstated `debug_assert!` fires and panics the -/// `world-writer` thread (caught here as `writer.join()` returning `Err`, not `Ok`). That panic is a -/// debug-profile-only tripwire, though: in the RELEASE profile the capacity benchmark and any live -/// deploy actually run, `debug_assert!` compiles out entirely and the `as u16` cast just wraps -/// silently — a header claiming a tiny size, followed by the full oversized body, corrupting every -/// packet after it. The mutation is caught here either way (panic in debug, corruption in release); -/// this test's own build only exercises the debug-panic half of that story. #[test] fn oversized_raw_body_ends_the_session_instead_of_wrapping_the_size_field() { let store = std::sync::Arc::new(InMemoryStore { diff --git a/gateway/tests/provision_cli.rs b/gateway/tests/provision_cli.rs index e6a759bd7..93852f273 100644 --- a/gateway/tests/provision_cli.rs +++ b/gateway/tests/provision_cli.rs @@ -4,10 +4,7 @@ use std::process::{Command, Output, Stdio}; fn run_gateway(args: &[&str], stdin: &[u8]) -> Output { let mut child = Command::new(env!("CARGO_BIN_EXE_lyracore-gateway")) .args(args) - // Keep the functional test isolated from any production/sharded environment inherited by - // the test runner. Individual tests can add the loopback-only values they need below. - // (These were `GW_*` until the rebrand — the stale names made every removal a no-op, so a - // runner with a sharded environment exported leaked it into the child unnoticed.) + // Isolate the child from inherited Realm topology and credentials. .env_remove("LYRACORE_COORDINATOR_TOKEN") .env_remove("LYRACORE_DATABASE") .env_remove("LYRACORE_SPACETIMEDB_URL") @@ -27,9 +24,7 @@ fn run_gateway(args: &[&str], stdin: &[u8]) -> Output { fn run_gateway_against_unreachable_loopback(args: &[&str], stdin: &[u8]) -> Output { let mut child = Command::new(env!("CARGO_BIN_EXE_lyracore-gateway")) .args(args) - // Rebrand fix: these were `GW_*`, which the gateway stopped reading — the URI override was - // dead, the child fell back to the DEFAULT http://127.0.0.1:3000, and the "unreachable" - // premise held only while nothing listened there (a live local dev node broke it). + // An unreachable loopback port keeps the connection failure independent of local nodes. .env("LYRACORE_COORDINATOR_TOKEN", "functional-test-token") .env("LYRACORE_DATABASE", "spacetime-core") .env("LYRACORE_SPACETIMEDB_URL", "http://127.0.0.1:0") diff --git a/gateway/tests/schema_parity.rs b/gateway/tests/schema_parity.rs index 79556f520..ad689dd07 100644 --- a/gateway/tests/schema_parity.rs +++ b/gateway/tests/schema_parity.rs @@ -413,10 +413,10 @@ fn field_shape(value: &T, ts: &mut RawModuleDefV9Builder) /// `name: value` way inside its own `{ .. }`). Brace/paren/bracket-depth aware; only records /// identifiers seen at depth 1 (directly inside the outermost struct's braces). /// -/// NOT string-literal aware (found by review): a Sentinel value whose Debug output embeds +/// Not string-literal aware: a Sentinel value whose Debug output embeds /// `word: ` inside a quoted string would inject a spurious identifier here. Every current sentinel /// is empty/zero/None so this cannot fire today, and an injection would surface as a LOUD -/// order-mismatch failure, not a silent pass — but keep Sentinel impls free of colon/comma-bearing +/// order-mismatch failure, not a silent pass; keep Sentinel impls free of colon/comma-bearing /// string values (or teach this parser about `"` first). fn top_level_debug_fields(debug: &str) -> Vec { let open = debug @@ -562,7 +562,7 @@ fn check(table: &str, binding: BindingShape, renames: &[(&str, if expected_b_name != m_name { // A rename entry may ONLY paper over the SDK's cosmetic trailing-digit underscore // normalization (data0 -> data_0). Without this guard, a PAIR of same-typed swapped - // fields could hide behind two compensating bogus renames (found by review). + // fields could hide behind two compensating bogus renames. assert_eq!( expected_b_name.replace('_', ""), m_name.replace('_', ""), @@ -717,7 +717,7 @@ parity_test!(parity_game_corpse_loot_eligible, "game_corpse_loot_eligible", lyra parity_test!(parity_game_group_event, "game_group_event", lyracore_module::GroupEvent, bindings::group_event_type::GroupEvent, { id, recipient_identity, kind, other_guid, other_name, created_at, payload, recipient_guid, }); -// The private per-recipient trade-status relay (#120) — the `game_group_event` shape minus the +// The private per-recipient trade-status relay — the `game_group_event` shape minus the // name/payload columns (no trade status carries either). parity_test!(parity_game_trade_event, "game_trade_event", lyracore_module::TradeEvent, bindings::trade_event_type::TradeEvent, { id, recipient_identity, kind, other_guid, created_at, recipient_guid, payload, diff --git a/importer/src/collision.rs b/importer/src/collision.rs index c2e946173..efb9cb748 100644 --- a/importer/src/collision.rs +++ b/importer/src/collision.rs @@ -1,4 +1,4 @@ -//! `--dump-collision ` — work-item 240 SPIKE: prove we can read WMO/M2 +//! `--dump-collision `, SPIKE: prove we can read WMO/M2 //! collision geometry out of the operator's own client MPQs before building the nav rasterizer //! (241). Same licensing firewall as `--dbc`/`--terrain`: in-memory read, nothing written. //! diff --git a/importer/src/dbc.rs b/importer/src/dbc.rs index fd12fa41f..c520d27db 100644 --- a/importer/src/dbc.rs +++ b/importer/src/dbc.rs @@ -209,10 +209,6 @@ pub fn run(data_dir: &str, args: &Args) -> Result<()> { let (si_stmts, si_count) = start_item_sql(&mut chain)?; let (property_stmts, _, _) = crate::item_property::catalogue_sql(&mut chain)?; - // Areas / area triggers / graveyards (work-item 209): AreaTable.dbc → game_area, AreaTrigger.dbc - // → game_area_trigger, WorldSafeLocs.dbc → game_graveyard. All small tables — load ALL rows (no - // map filtering here; that's a verify-time concern, see import-world.sh). All no-Timestamp → - // plain SQL, same clear+reload shape as the blocks above. let area_table: DbcAreaTable = read_table(&mut chain)?; let (area_stmts, area_count) = area_sql(&area_table); let area_triggers: DbcAreaTrigger = read_table(&mut chain)?; @@ -220,7 +216,7 @@ pub fn run(data_dir: &str, args: &Args) -> Result<()> { let safe_locs: DbcWorldSafeLocs = read_table(&mut chain)?; let (graveyard_stmts, graveyard_count) = graveyard_sql(&safe_locs); - // Creature families (work-item 214): CreatureFamily.dbc → game_creature_family. Small table (no + // Creature families : CreatureFamily.dbc → game_creature_family. Small table (no // map filtering — same "load ALL rows" convention as the other DBC-backed lookups above). No // Timestamp → plain SQL, same clear+reload shape. let creature_families: DbcCreatureFamily = read_table(&mut chain)?; @@ -231,7 +227,7 @@ pub fn run(data_dir: &str, args: &Args) -> Result<()> { .filter(|r| r.pet_talent_type != -1) .count(); - // Locks (work-item 211): Lock.dbc → game_lock, the DATA half of open-lock (119 wires enforcement). + // Locks : Lock.dbc → game_lock, the DATA half of open-lock (119 wires enforcement). // Small table (a few hundred rows in vanilla) — load ALL, same "load ALL rows" convention as the // other DBC-backed lookups above. No Timestamp → plain SQL, same clear+reload shape. let locks: DbcLock = read_table(&mut chain)?; @@ -254,7 +250,7 @@ pub fn run(data_dir: &str, args: &Args) -> Result<()> { } // Load game_skill_line + game_skill_ability + game_skill_availability from SkillLine.dbc / - // SkillLineAbility.dbc / SkillRaceClassInfo.dbc (work-item 208: the skill fabric as data — see + // SkillLineAbility.dbc / SkillRaceClassInfo.dbc: the skill fabric as data, see // module/src/skilldata.rs). All no-Timestamp → plain SQL, same clear+reload shape as the blocks // above. `SkillTiers.dbc` is NOT loaded here (no `game_*` table of its own) — it only feeds // `profession_tier_values` below, which `importer/src/main.rs` uses for the profession trainer @@ -332,13 +328,13 @@ pub fn run(data_dir: &str, args: &Args) -> Result<()> { println!("SkillRaceClassInfo: {sav_count} availability rows"); println!("AuctionHouse: {auction_house_count} houses"); println!("MailTemplate: {mail_template_count} templates"); - // Work-item 209 coverage prints (always printed, like the skill lines above). + // coverage prints (always printed, like the skill lines above). println!("AreaTable: {area_count} areas"); println!("AreaTrigger: {trigger_count} triggers"); println!("WorldSafeLocs: {graveyard_count} graveyards"); - // Work-item 214 coverage print (always printed, like the ones above). + // coverage print (always printed, like the ones above). println!("CreatureFamily: {family_count} families ({tameable_family_count} tameable)"); - // Work-item 211 coverage print (always printed, like the ones above). Loud about any Lock.dbc + // coverage print (always printed, like the ones above). Loud about any Lock.dbc // LocktypeReference id `LOCKTYPE_TO_SKILL_LINE` doesn't cover — see that const's doc comment. if lock_unmapped.is_empty() { println!("Lock: {lock_count} lock indices"); @@ -511,7 +507,7 @@ fn char_base_info_sql(chain: &mut PatchChain) -> Result<(Vec, usize)> { } /// The `CharStartOutfit.dbc` (race_class, item) set — shared by `start_item_sql` (the outfit's own -/// clear+reload) AND `main.rs`'s `build_createinfo_item_sql` (work-item 212: the cmangos dump's +/// clear+reload) AND `main.rs`'s `build_createinfo_item_sql`: the cmangos dump's /// `playercreateinfo_item` EXTRAS are deduped against this SAME set, so an item CharStartOutfit /// already grants for a (race, class) is never double-listed). `race == 0 || class == 0` rows are /// skipped (not a real playable combo) and `item == 0` slots (empty) are skipped, matching @@ -536,15 +532,6 @@ pub(crate) fn outfit_item_set( Ok(seen) } -/// Clear+reload SQL for `game_start_item` from `CharStartOutfit.dbc` — the per-(race,class) creation -/// loadout (the cmangos dump's `playercreateinfo_item` used to be treated as EMPTY; work-item 212 now -/// imports its EXTRAS separately — see `main.rs::build_createinfo_item_sql`, deduped against the SAME -/// (race_class, item) pairs this function writes via `outfit_item_set`). One row per non-empty item -/// slot, DEDUPED on (race_class, item) so the male+female outfits (identical item ids, differing only -/// in display) don't double-grant. Keyed by race_class = (race<<8)|class, matching game_start_position. -/// Ids are assigned densely from 1 — `build_createinfo_item_sql` uses a disjoint high id range -/// (`CREATEINFO_ITEM_ID_FLOOR`) so the two writers' DELETEs never touch each other's rows, regardless -/// of which pass runs first. No Timestamp → plain SQL. fn start_item_sql(chain: &mut PatchChain) -> Result<(Vec, usize)> { let outfits: DbcCharStartOutfit = read_table(chain)?; let mut seen: std::collections::HashSet<(u32, i32)> = std::collections::HashSet::new(); @@ -579,7 +566,7 @@ fn start_item_sql(chain: &mut PatchChain) -> Result<(Vec, usize)> { /// this way). The DBC carries no strings for this enum, so this is sourced from community DBC /// documentation (wowdev.wiki), not something verified against our own client dump — it's informational /// only HERE (it only drives the "N autolearn" coverage-print count); a future AUTOLEARN consumer -/// (work-item 208's "auto-learned spells at thresholds" follow-up) is what needs this value truly +/// Automatic spell learning at level thresholds is what needs this value truly /// load-bearing-correct, and can re-derive/correct it against a real client at that point. const AUTOLEARN_ACQUIRE_METHOD: i32 = 1; @@ -650,12 +637,6 @@ fn skill_ability_sql(table: &DbcSkillLineAbility) -> (Vec, usize) { .iter() .enumerate() .map(|(i, r)| { - // ⚠ wow_dbc 0.3 SkillLineAbilityRow MIS-NAMES every field from position 8 (work-item - // 282): it omits the real `MinSkillLineRank` column, so each field's NAME lags the real - // column by one while the byte OFFSET stays correct. CONFIRMED against the live import: - // the field wow_dbc calls `acquire_method` holds SupercededBySpell SPELL IDS (thousands), - // and the real 0/1/2 `AcquireMethod` enum sits in `trivial_skill_line_rank_high`. Remap - // by CONTENT (same off-by-one class as the Spell.dbc InterruptFlags bug in spell.rs): let min_skill = r.superseded_by.id; // real MinSkillLineRank let acquire_method = r.trivial_skill_line_rank_high; // real AcquireMethod (0/1/2) let gray = r.trivial_skill_line_rank_low; // real TrivialSkillLineRankHigh @@ -799,7 +780,7 @@ fn faction_template_sql(chain: &mut PatchChain) -> Result<(Vec, usize)> Ok((stmts, n)) } -/// Clear+reload SQL for `game_area` from `AreaTable.dbc` (work-item 209): every zone AND subzone row +/// Clear+reload SQL for `game_area` from `AreaTable.dbc` : every zone AND subzone row /// (id, map, parent, area_bit, flags, exploration level, faction group, name). `flags` is the raw /// `AreaFlags` bitmask reinterpreted as `u32` (`as_int()` widened) — undecoded here, a consumer /// decodes what it needs (rest-state city detection is 196's job). Name via `.en_gb` + `sql_text` @@ -834,7 +815,7 @@ fn area_sql(table: &DbcAreaTable) -> (Vec, usize) { (stmts, n) } -/// Clear+reload SQL for `game_area_trigger` from `AreaTrigger.dbc` (work-item 209): the geometric +/// Clear+reload SQL for `game_area_trigger` from `AreaTrigger.dbc` : the geometric /// half of inn triggers (196), dungeon entrances (190), and quest explore objectives — a trigger /// volume is either a sphere (`radius`) or a box (`box_length`/`box_width`/`box_height`/`box_yaw`); /// the DBC carries both fields for every row regardless of which shape a given trigger actually @@ -871,8 +852,8 @@ fn area_trigger_sql(table: &DbcAreaTrigger) -> (Vec, usize) { (stmts, n) } -/// Clear+reload SQL for `game_graveyard` from `WorldSafeLocs.dbc` (work-item 209): a graveyard's -/// fixed position, replacing the hand-coded `world::graveyard::{NORTHSHIRE, GOLDSHIRE, ...}` consts +/// Clear+reload SQL for `game_graveyard` from `WorldSafeLocs.dbc` : a graveyard's +/// fixed position, replacing the hand-coded `world::graveyard::{NORTHSHIRE, GOLDSHIRE,...}` consts /// as the primary data source (those consts — and `seed.rs`'s row-seeded twins — remain the /// no-import fallback). No orientation column — the DBC carries none. Name via `.en_gb` + /// `sql_text`. Loads ALL rows (small table). No Timestamp → plain SQL. @@ -1214,7 +1195,7 @@ fn taxi_catalogue_sql( }) } -/// Clear+reload SQL for `game_creature_family` from `CreatureFamily.dbc` (work-item 214: the 188 pet +/// Clear+reload SQL for `game_creature_family` from `CreatureFamily.dbc`: the 188 pet /// system's data half — `CreatureTemplate.creature_family`/`type_flags` already import via /// `main.rs`'s `ct::FAMILY`/`ct::CREATURE_TYPE_FLAGS`, and the Wolf faction fixup already reads /// `family_id` off the dump; this is the last missing piece, the family lookup table itself). Name @@ -1266,7 +1247,7 @@ fn locktype_skill_line(locktype_id: u32) -> Option { .map(|&(_, skill)| skill) } -/// Clear+reload SQL for `game_lock` from `Lock.dbc` (work-item 211 — the DATA half of open-lock; 119 +/// Clear+reload SQL for `game_lock` from `Lock.dbc`, the DATA half of open-lock; 119 /// wires the enforcement). Each Lock.dbc row packs FOUR parallel `[;8]` arrays: `ty`/`property`/ /// `required_skill`/`action` — up to 8 ALTERNATIVE ways to open the SAME lock (e.g. "the right key OR /// enough Lockpicking"). Only the non-`LockType::None` indices are real; a lock with 1 requirement @@ -1356,10 +1337,6 @@ fn resolve_profession_tier_values( ) -> HashMap { let mut out = HashMap::new(); for &line in PROFESSION_SKILL_LINES { - // A line can carry SEVERAL SkillRaceClassInfo rows (race/class variants, trainer-visibility - // flag rows) and not all of them carry the tier FK — take the first row whose skill_tier - // RESOLVES, not the first row that merely matches the line (first-match spuriously failed - // the parity guard when a non-tier variant row sorted first — review catch). let Some(tier) = availability .rows() .iter() @@ -2072,7 +2049,7 @@ mod tests { // A CONSTRUCTED fixture (not client-verified — this sandbox has no real client MPQ to read a // real trigger id from) shaped like Goldshire's Lion's Pride Inn entrance trigger, proving // the emitted tuple's column order — id,map_id,x,y,z,radius,box_length,box_width,box_height, - // box_yaw — is exactly what work-item 196's rest-state system will read a real inn + // box_yaw, is exactly what the rest-state system will read a real inn // AreaTrigger row as. let table = DbcAreaTrigger { rows: vec![area_trigger_row( @@ -2520,9 +2497,6 @@ mod tests { #[test] fn profession_tier_values_skips_past_a_non_tier_variant_row_to_the_resolving_one() { - // Real SkillRaceClassInfo carries multiple rows per line (race/class variants, visibility - // flag rows) and not all carry the tier FK. A non-resolving row sorted FIRST must not sink - // the line — the resolver scans until a skill_tier resolves (the review's spurious-bail case). let availability = DbcSkillRaceClassInfo { rows: vec![ skill_availability_row(1, 186 /* Mining */, 0x1, 0x1, 0, 1, 0), // variant: tier FK 0 → unresolvable diff --git a/importer/src/go_model.rs b/importer/src/go_model.rs index b16e18a58..e75eb916c 100644 --- a/importer/src/go_model.rs +++ b/importer/src/go_model.rs @@ -95,8 +95,6 @@ struct DoorTemplate { size: f32, } -/// Every `gameobject_template` row that classifies as DOOR/BUTTON (`classify_go_type` 0/1) — every -/// other type (CHEST, GOOBER, GATHER, ...) is out of scope for this slice's collider geometry. fn door_templates(dump: &str) -> Vec { parse_table(dump, "gameobject_template") .iter() diff --git a/importer/src/main.rs b/importer/src/main.rs index 30b89c705..9fa4a57d9 100644 --- a/importer/src/main.rs +++ b/importer/src/main.rs @@ -29,7 +29,7 @@ //! --nav WMO/M2 nav-grid rasterizer (nav.rs) //! --vmap exact bounded collision-triangle extract + pack + import //! (--apply loads `game_vmap_chunk` via import_vmap_chunks; a -//! dry run stops at report — vmap.rs; #520/#521, +//! dry run stops at report, vmap.rs, //! docs/decisions.md §10) //! --vmap-prepare-coverage derive path-grid coverage for an already-staged vmap //! generation (--apply calls prepare_/finalize_vmap_nav_coverage, @@ -105,14 +105,14 @@ mod ct { pub const RANK: usize = 33; pub const MIN_LEVEL_HEALTH: usize = 40; pub const MAX_LEVEL_HEALTH: usize = 41; - pub const MIN_MELEE_DMG: usize = 44; // float; per-creature swing damage (parity #7) + pub const MIN_MELEE_DMG: usize = 44; // float; per-creature swing damage pub const MAX_MELEE_DMG: usize = 45; pub const ARMOR: usize = 48; // → game_creature_template.armor (0 = unmitigated, module fallback) pub const MELEE_BASE_ATTACK_TIME: usize = 51; pub const MIN_LOOT_GOLD: usize = 54; pub const MAX_LOOT_GOLD: usize = 55; pub const LOOT_ID: usize = 56; // → creature_loot_template.entry (0 = no loot table) - // Loot-family completeness (work-item 210): PickpocketLootId/SkinLootId sit IMMEDIATELY after LootId + // Loot-family completeness : PickpocketLootId/SkinLootId sit IMMEDIATELY after LootId // (LootId, PickpocketLootId, SkinningLootId are consecutive columns). These two carried a `[V]` // "confirm against your own dump" caveat until the pinned dump's DDL was enumerated directly — // both are now confirmed, along with every other const here. @@ -134,12 +134,12 @@ mod gm { pub const CONDITION_ID: usize = 3; } mod npct { - // npc_text (full 81-col format): ID, text0_0, text0_1, lang0, prob0, em0_0..5, text1_0, ... + // npc_text (full 81-col format): ID, text0_0, text0_1, lang0, prob0, em0_0..5, text1_0,... pub const ID: usize = 0; pub const NCOLS_FULL: usize = 81; // 1 + 8*(2+1+1+6) = 81 (old schema with embedded strings) // New cmangos schema packs each slot as a single BroadcastTextId. 17 cols: ID + 8 probs + 8 ids. pub const NCOLS_SHORT: usize = 17; // ID, Prob0..7, BroadcastTextId0..7 - /// Full-schema per-slot column formula (work-item 217, all 8 slots not just the first): each slot + /// Full-schema per-slot column formula (all 8 slots not just the first): each slot /// occupies 10 columns starting at `1 + slot*10` — male, female, lang, prob, em0..em5. pub const fn full_slot_base(slot: usize) -> usize { 1 + slot * 10 @@ -156,7 +156,7 @@ mod npct { } } pub(crate) mod bt { - // broadcast_text: Id, Text (male), Text1 (female), ChatTypeID, LanguageID, ... + // broadcast_text: Id, Text (male), Text1 (female), ChatTypeID, LanguageID,... pub const ID: usize = 0; pub const TEXT: usize = 1; // male greeting text pub const TEXT1: usize = 2; // female greeting text @@ -212,7 +212,7 @@ mod cts { } mod cr { // creature (spawn) — ClassicDB_1_12_1_z2815: guid,id,map,spawnMask,position_x,position_y, - // position_z,orientation,spawntimesecsmin,spawntimesecsmax,spawndist,MovementType,... + // position_z,orientation,spawntimesecsmin,spawntimesecsmax,spawndist,MovementType... pub const GUID: usize = 0; pub const ID: usize = 1; // == creature_template.Entry pub const MAP: usize = 2; @@ -220,7 +220,7 @@ mod cr { pub const Y: usize = 5; pub const Z: usize = 6; pub const O: usize = 7; - pub const SPAWN_TIME_SECS_MIN: usize = 8; // work-item 047: cmangos spawntimesecsmin (seconds, from death) + pub const SPAWN_TIME_SECS_MIN: usize = 8; pub const MOVEMENT_TYPE: usize = 11; // 0 idle / 1 random-wander / 2 waypoint } mod cm { @@ -232,7 +232,7 @@ mod cm { } mod cmt { // cmangos `creature_movement_template` — the ENTRY-keyed twin of `creature_movement`'s - // guid-keyed paths (work-item 226: Deadmines patrols whose MovementType=2 spawns carry no + // guid-keyed paths: Deadmines patrols whose MovementType=2 spawns carry no // per-guid rows). [V] BOTH vintages below are cmangos-1.12-from-knowledge, NOT verified against // an in-repo dump (none in this sandbox), and no neighbor-anchor is possible (first import from // this table). Vintage is sniffed by COLUMN COUNT, the `npct`/`gmo` precedent: @@ -322,7 +322,7 @@ mod gz { pub const FACTION: usize = 2; } mod at { - // cmangos `areatrigger_teleport` — VERIFIED against the real dump 2026-07-10 (work-item 245; the + // cmangos `areatrigger_teleport`, VERIFIED against the real dump 2026-07-10; the // original [V] guess was off by FOUR columns and every imported portal row was garbage — the // Deadmines "no entrance" gap). Real row shape, read off the dump's own INSERT: // (id, name, required_level, req_item, req_quest?, req_?, target_map, x, y, z, o, message, ?) @@ -345,7 +345,7 @@ mod it { pub const NAME: usize = 3; pub const DISPLAY_ID: usize = 4; pub const QUALITY: usize = 5; - // Flags (work-item 213): the raw item_template.Flags bitmask (unique/conjured/etc — ItemFlag on + // Flags : the raw item_template.Flags bitmask (unique/conjured/etc, ItemFlag on // the wire). Sits right after Quality(5), before BuyCount(7)/BuyPrice(8) — anchored by the // already-verified BUY_PRICE=8 two columns later (Quality(5), Flags(6), BuyCount(7), BuyPrice(8) // is the standard cmangos 1.12 item_template column run). @@ -361,17 +361,17 @@ mod it { // sentinel; restrictive masks are unsigned bits and must stay byte-for-byte intact. pub const ALLOWED_CLASS: usize = 11; pub const ALLOWED_RACE: usize = 12; - // RequiredSkill/RequiredSkillRank (work-item 213): weapon-skill / mail-plate proficiency gate. + // RequiredSkill/RequiredSkillRank : weapon-skill / mail-plate proficiency gate. // Anchored tightly between the already-verified REQUIRED_LEVEL(14) and STACKABLE(23): 14 // RequiredLevel, 15 RequiredSkill, 16 RequiredSkillRank, 17 RequiredSpell, 18 RequiredHonorRank, // 19 RequiredCityRank, 20 RequiredReputationFaction, 21 RequiredReputationRank, 22 maxcount, // 23 Stackable(confirmed) — the run lands exactly on the existing STACKABLE=23 anchor. pub const REQUIRED_SKILL: usize = 15; pub const REQUIRED_SKILL_RANK: usize = 16; - // RequiredReputationFaction/Rank (work-item 213): the 195 item half. Same anchored run as above. + // RequiredReputationFaction/Rank : the 195 item half. Same anchored run as above. pub const REQUIRED_REPUTATION_FACTION: usize = 20; pub const REQUIRED_REPUTATION_RANK: usize = 21; - // maxcount (work-item 213): unique-item stack cap. Same anchored run — one column before the + // maxcount : unique-item stack cap. Same anchored run, one column before the // already-verified STACKABLE=23. pub const MAX_COUNT: usize = 22; pub const ITEM_LEVEL: usize = 13; @@ -382,7 +382,7 @@ mod it { pub const DMG_MIN1: usize = 45; pub const DMG_MAX1: usize = 46; pub const ARMOR: usize = 60; - // The 6 resistance schools (work-item 213) — cmangos lays them out immediately after Armor(60) + // The 6 resistance schools, cmangos lays them out immediately after Armor(60) // and immediately before the already-verified DELAY=67: 60 Armor, 61 HolyRes, 62 FireRes, // 63 NatureRes, 64 FrostRes, 65 ShadowRes, 66 ArcaneRes, 67 Delay(confirmed) — the run lands // exactly on the existing DELAY anchor. @@ -399,7 +399,7 @@ mod it { pub const SPELLTRIGGER_1: usize = 71; pub const SPELLID_2: usize = 77; pub const SPELLTRIGGER_2: usize = 78; - // Spell slots 3-5 (work-item 213): each slot is 7 columns wide, same shape as slots 1-2 above. + // Spell slots 3-5 : each slot is 7 columns wide, same shape as slots 1-2 above. // Slot 3 starts right after slot 2's 7 columns (77..=83), slot 4 after slot 3's (84..=90), slot 5 // after slot 4's (91..=97) — and slot 5's own 7 columns (98..=104) land exactly on the // already-verified BONDING=105 anchor one column later, confirming all three slots. @@ -409,18 +409,18 @@ mod it { pub const SPELLTRIGGER_4: usize = 92; pub const SPELLID_5: usize = 98; pub const SPELLTRIGGER_5: usize = 99; - pub const BONDING: usize = 105; // item binding (work-item 127): 0=NoBind,1=BoP,2=BoE,3=BoU,4/5=QuestItem - // description(106), PageText(107), LanguageID(108), PageMaterial(109), startquest(110), - // lockid(111), Material(112) sit between BONDING(105, confirmed) and the already-verified - // SHEATH=113 below — PAGE_TEXT and START_QUEST land on that same anchored run. - pub const PAGE_TEXT: usize = 107; // readable-item page id (work-item 213: needs its own reader packet, deferred) - pub const START_QUEST: usize = 110; // quest-starter link (work-item 213: 194 consumes) + pub const BONDING: usize = 105; + // description(106), PageText(107), LanguageID(108), PageMaterial(109), startquest(110), + // lockid(111), Material(112) sit between BONDING(105, confirmed) and the already-verified + // SHEATH=113 below, PAGE_TEXT and START_QUEST land on that same anchored run. + pub const PAGE_TEXT: usize = 107; + pub const START_QUEST: usize = 110; pub const SHEATH: usize = 113; // sheath posture, sent verbatim to the client (0=no stow, 1H sword=3, // shield=4 — opaque client indices, see items/tables.rs::sheath) pub const RANDOM_PROPERTY: usize = 114; pub const BLOCK: usize = 115; // shield block value (CREATE TABLE: …RandomProperty(114), block(115), itemset(116)) pub const MAX_DURABILITY: usize = 117; - // BagFamily (work-item 213): bag-type restriction bitmask. Anchored right after the + // BagFamily : bag-type restriction bitmask. Anchored right after the // already-verified MAX_DURABILITY=117: 117 MaxDurability(confirmed), 118 area, 119 Map, // 120 BagFamily. pub const BAG_FAMILY: usize = 120; @@ -460,11 +460,10 @@ mod qt { // [V] LimitTime (seconds from accept, 0 = untimed) — unverified column index (no cmangos dump in // this sandbox to confirm against). Anchored by bracketing against the two already-trusted // constants either side: cmangos's real `quest_template` layout runs - // ...RequiredMaxRepValue, SuggestedPlayers, LimitTime, QuestFlags, SpecialFlags... so col 19 sits // exactly 2 slots before the already-verified SPECIAL_FLAGS=21 (QuestFlags at 20 between them). // Coverage-printed at import time (timed-quest count) rather than hard floor-gated — plausible // this Elwynn/Westfall slice has ZERO genuinely timed quests (escort quests, the usual timed - // pairing, are deliberately out of scope per work-item 194), so a floor here would risk a false + // pairing, are deliberately out of scope per, so a floor here would risk a false // failure on a legitimate import; the coverage print is the fail-LOUD net instead. pub const LIMIT_TIME: usize = 19; pub const SPECIAL_FLAGS: usize = 21; // bit0 = QUEST_SPECIAL_FLAGS_REPEATABLE @@ -480,7 +479,7 @@ mod qt { pub const OBJECTIVES: usize = 32; // objective summary line(s) pub const OFFER_REWARD_TEXT: usize = 33; // text on the turn-in reward screen pub const REQUEST_ITEMS_TEXT: usize = 34; // text on the "not finished yet" screen - pub const REQ_ITEM_ID1: usize = 40; // ..=43; collect-objective item entries (parity #4) + pub const REQ_ITEM_ID1: usize = 40; // ..=43; collect-objective item entries pub const REQ_ITEM_COUNT1: usize = 44; // ..=47 pub const REQ_CREATURE_OR_GO_ID1: usize = 56; // ..=59; >0 = creature entry, <0 = gameobject (skip) pub const REQ_CREATURE_OR_GO_COUNT1: usize = 60; // ..=63 @@ -506,7 +505,7 @@ mod qt { mod dse { // dbscripts_on_quest_end — verified against the pinned dump's own `CREATE TABLE`: id, delay, // priority, command, datalong, datalong2, datalong3, buddy_entry, search_radius, data_flags, - // dataint, .... SCRIPT_COMMAND_SEND_MAIL (cm:ScriptMgr.cpp:2850-2866) is the only command this + // dataint,.... SCRIPT_COMMAND_SEND_MAIL (cm:ScriptMgr.cpp:2850-2866) is the only command this // importer reads from this table. pub const ID: usize = 0; // quest_template.entry the script fires on turn-in pub const COMMAND: usize = 3; @@ -532,7 +531,7 @@ mod mlt { mod qr { // creature_questrelation (START) AND creature_involvedrelation (END) share this 2-col layout — // as do their GAMEOBJECT twins gameobject_questrelation (START) / gameobject_involvedrelation - // (END): id = gameobject_template.entry, quest = quest_template.entry (work-item 041). + // (END): id = gameobject_template.entry, quest = quest_template.entry. pub const CREATURE: usize = 0; // id = creature_template.entry (or gameobject_template.entry for the GO tables) pub const QUEST: usize = 1; } @@ -606,13 +605,6 @@ fn gather_node(entry: u64) -> Option<(u32, u32, u32, u32)> { .map(|n| (n.1, n.2, n.3, n.4)) } -/// This repo's stored `game_gameobject_template.type_id` values (mirrors module `go_type::*`, -/// `module/src/gameobject.rs` — importer is a separate crate, so the literal is re-declared here -/// rather than shared). LIVE = dispatched by `apply_use_gameobject`; QUESTGIVER imports unchanged (no -/// dispatch, its gate lives in `game_gameobject_quest`); every OTHER value this slice produces is -/// INERT (template+spawn only, `use` is a no-op) — see `classify_go_type` (work-item 211's widened -/// import: CHEST/DOOR/BUTTON join GOOBER/GATHER as LIVE; everything else that used to be filtered out -/// entirely now imports inert instead). pub(crate) const GO_DOOR: u8 = 0; pub(crate) const GO_BUTTON: u8 = 1; const GO_QUESTGIVER: u8 = lyracore_shared::constants::go_type::QUESTGIVER; // shared const (041) — no drift @@ -629,14 +621,6 @@ const CMANGOS_FISHINGHOLE_TYPE: u32 = 25; /// Classify one `gameobject_template` row's cmangos TYPE into this repo's stored `type_id` — `None` /// means DROP the row from import entirely (template AND spawn). Pure + fixture-tested. /// -/// TYPE-25 COLLISION GUARD (highest-severity risk this work item touches): cmangos assigns REAL type -/// 25 to GAMEOBJECT_TYPE_FISHINGHOLE, which happens to collide with this repo's SYNTHETIC `GO_GATHER` -/// marker (chosen back when vanilla had no real type 25 in scope — see the module's `go_type::GATHER` -/// doc). `GATHER_NODES` is the ONLY producer of a stored type 25 (checked FIRST, before the raw-25 -/// arm below, so a type-3 gather node never falls through to the guard); any REAL cmangos type-25 row -/// is dropped here rather than risk a fishing hole being silently treated as (or colliding with) a -/// gather node by any current or future GATHER-keyed logic. -/// /// Every other in-scope raw type passes through IDENTITY (this repo's type ids match cmangos' for /// every value except the 3→25 gather remap and the 25 drop) — `main.rs`'s per-type dispatch decides /// LIVE vs INERT from the resulting value, not from this function. @@ -649,19 +633,6 @@ pub(crate) fn classify_go_type(entry: u64, raw_type: u32) -> Option { } } -/// Build ONE `game_gameobject_template` SQL row-tuple for an ALREADY-CLASSIFIED GO (work-item 211). -/// `data0`/`data1` are the RAW dump columns (positionally identical across every cmangos GO type — -/// `got::DATA0`/`got::DATA1`). Carries the cmangos lockId into the END-appended `lock_id` column: -/// CHEST/GOOBER source it from `data0` (CHEST repo-verified — see `got` module doc; GOOBER [V]); -/// DOOR/BUTTON from `data1` ([V] — cmangos DOOR/BUTTON data0=startOpen, data1=lockId); everything else -/// carries 0 (no lock concept this slice, or GATHER's skill-gate stays hand-coded per the plan). -/// `data0`/`data1` template COLUMNS (as opposed to the dump's raw data0/data1) keep their PRE-211 -/// meanings: CHEST.data0 is the legacy single-item fallback (stays 0 for a real imported chest — -/// unchanged), CHEST.data1 is the real `gameobject_loot_template` lootId (unchanged, now for every -/// chest instead of a curated allowlist). Returns the row tuple AND, for a CHEST with a nonzero -/// lootId, that lootId (to fold into `chest_loot_ids_used` — widened to EVERY imported chest). -/// `size` rides verbatim into the END-appended `size` column for EVERY type; a 0 (absent/corrupt -/// dump column) stays 0, which the gateway codec renders as 1.0. fn go_template_row( entry: u64, stored_type: u8, @@ -720,7 +691,7 @@ fn go_initial_state(stored_type: u8, data0: u32) -> u8 { } mod nt { - // npc_trainer: entry(=creature_template.entry), spell, spellcost, reqskill, reqskillvalue, reqlevel, ... + // npc_trainer: entry(=creature_template.entry), spell, spellcost, reqskill, reqskillvalue, reqlevel,... pub const ENTRY: usize = 0; pub const SPELL: usize = 1; pub const COST: usize = 2; @@ -738,7 +709,7 @@ mod nt { /// unlike the old 50080-88 markers) + learn_skill_line/learn_skill_cap set (the module routes the buy to /// crate::skill::learn_profession instead of a spell). Tuple: (skill_line, &[(spell_id, tier_index, /// required_level)]) — `tier_index` (0=Apprentice,1=Journeyman,2=Expert,3=Artisan) replaced a literal cap -/// (work-item 208): the real cap now comes from `SkillTiers.dbc` (see `dbc::profession_tier_values`), +/// the real cap now comes from `SkillTiers.dbc` (see `dbc::profession_tier_values`), /// checked against `LEGACY_TIER_CAPS` by `assert_profession_tier_parity` before it's ever emitted — this /// table itself no longer carries the number, just WHICH tier. The gather/secondary lines (Mining/ /// Herbalism/Skinning/Cooking/First Aid) stop at Expert(2) HERE — their Artisan(3) tier is deferred (not @@ -784,7 +755,7 @@ const PROFESSION_LEARN: &[(u32, &[(u32, u8, u8)])] = &[ ]; /// The legacy Apprentice/Journeyman/Expert/Artisan tier caps `PROFESSION_LEARN` hardcoded before -/// work-item 208 wired in the real client's `SkillTiers.dbc` data — every profession in vanilla rides +/// wired in the real client's `SkillTiers.dbc` data, every profession in vanilla rides /// this exact 75/150/225/300 progression, indexed by `tier_index`. `assert_profession_tier_parity` /// checks the DBC-derived caps against this constant; the synthesis loop (below) falls back to it /// directly when `--dbc` wasn't given (no client data available to check against, so no parity guard @@ -801,12 +772,8 @@ fn profession_tiers(line: u32) -> Option<&'static [(u32, u8, u8)]> { .map(|(_, t)| *t) } -/// PARITY GUARD (work-item 208): for every `PROFESSION_LEARN` entry, assert the REAL client's -/// `SkillRaceClassInfo.dbc`/`SkillTiers.dbc` data (`tier_values`, from `dbc::profession_tier_values`) -/// resolves that `(skill_line, tier_index)` to the exact `LEGACY_TIER_CAPS` cap this importer hardcoded -/// before the DBC import existed. A mismatch means the operator's client disagrees with our assumption -/// that every profession rides the identical 75/150/225/300 progression — bail loudly (naming the -/// offending line + tier) rather than silently emitting a wrong cap into `game_trainer_spell`. +/// Verify client skill-tier values match the importer's 75/150/225/300 progression. +/// Refuse mismatches before writing trainer offers. pub(crate) fn assert_profession_tier_parity( tier_values: &std::collections::HashMap, ) -> Result<()> { @@ -839,9 +806,7 @@ mod sc { pub const REQ_SPELL: usize = 4; // [V] } mod sls { - // spell_learn_spell (cmangos/mangos-classic 1.12 schema): entry(parent spell), SpellID(dependent to - // auto-learn), Active. [V] — same anchoring caveat as `sc` above. `ACTIVE` is parsed (so a future - // gate can read it) but not consulted by `build_spell_learn_sql` this slice — see its doc. + pub const ENTRY: usize = 0; // [V] parent_spell pub const SPELL_ID: usize = 1; // [V] learn_spell pub const ACTIVE: usize = 2; // [V] parsed-but-unused @@ -873,7 +838,7 @@ mod spe { pub const COLUMNS: usize = 9; } pub(crate) mod got { - // gameobject_template: entry, type, displayId, name, faction, flags, ExtraFlags, size, data0..23, ... + // gameobject_template: entry, type, displayId, name, faction, flags, ExtraFlags, size, data0..23,... pub const ENTRY: usize = 0; pub const TYPE: usize = 1; pub const DISPLAY_ID: usize = 2; @@ -881,13 +846,7 @@ pub(crate) mod got { // The prop's render scale (OBJECT_FIELD_SCALE_X); gathering nodes are authentically sub-1.0. // Carried verbatim — the 0/absent → 1.0 fallback lives in the gateway codec, not here. pub const SIZE: usize = 7; - // Positionally identical across EVERY cmangos GO type (only the per-type MEANING differs — see - // `go_template_row`'s doc): CHEST data0=lockId (repo-verified) data1=lootId (a - // gameobject_loot_template entry, work-item 210); DOOR/BUTTON data0=startOpen data1=lockId [V]; - // GOOBER data0=lockId [V]. GATHER nodes read NEITHER (the granted item + required level come from - // the hardcoded GATHER_NODES map, not Lock.dbc — see that const's doc). `go_template_row` (work-item - // 211) is what routes DATA0/DATA1 into the right module column per type; this module just names the - // raw dump columns. + pub const DATA0: usize = 8; pub const DATA1: usize = 9; /// MEETINGSTONE: the dungeon's AreaTable id. @@ -896,7 +855,7 @@ pub(crate) mod got { pub const DATA5: usize = 13; } mod go { - // gameobject (spawn): guid, id(=gameobject_template.entry), map, spawnMask, x, y, z, orientation, rot0..3, ... + // gameobject (spawn): guid, id(=gameobject_template.entry), map, spawnMask, x, y, z, orientation, rot0..3,... pub const GUID: usize = 0; pub const ID: usize = 1; pub const MAP: usize = 2; @@ -904,7 +863,7 @@ mod go { pub const Y: usize = 5; pub const Z: usize = 6; pub const O: usize = 7; - // The cmangos spawn quaternion (issue #515) — the client's real prop orientation, `orientation` + // The cmangos spawn quaternion, the client's real prop orientation, `orientation` // (O, above) alone only drives movement-facing math. Positionally right after O per the dump // schema comment; carried through byte-verbatim (no derive/snap here — that fallback lives in // the gateway codec for the all-zero case, not the importer). @@ -939,7 +898,7 @@ pub(crate) fn parse_table(dump: &str, table: &str) -> Vec> { let c = bytes[i] as char; if in_quote { if c == '\\' && i + 1 < bytes.len() { - // escaped char — take the next literally (\' \\ \n ...) + // escaped char, take the next literally (\' \\ \n...) let n = bytes[i + 1] as char; field.push(match n { 'n' => '\n', @@ -1036,13 +995,13 @@ pub(crate) struct Args { pub(crate) terrain: Option, // client Data/ dir for the ADT heightmap stream (see terrain.rs) pub(crate) dump_collision: Option, // client Data/ dir: 240 spike, WMO/M2 collision dry-run (see collision.rs) pub(crate) nav: Option, // client Data/ dir: 241 nav-grid rasterizer (see nav.rs) - pub(crate) vmap: Option, // client Data/ dir: #520/#521 exact vmap triangle extract+pack+import (see vmap.rs) + pub(crate) vmap: Option, // Client geometry input for the per-cell collision catalogue. pub(crate) vmap_status: bool, // print active-generation provenance/status without opening client data pub(crate) vmap_prepare_coverage: Option, // generation id: derive path-grid coverage from an already-staged generation (see vmap.rs::run_coverage) pub(crate) go_models: Option, // client Data/ dir: DOOR/BUTTON display -> M2 bounding-mesh extract+import (see go_model.rs); needs --dump too pack_client: Option, // client Data/ dir for the --pack-client packager (see pack_client.rs) pack_out: Option, // output dir for the --pack-out packager: package-authored client content only (see pack_client.rs) - print_extents: bool, // with --dump: print the operator's own spawn bbox for --map and exit (work-item 206) + print_extents: bool, spells: bool, // with --dbc: import Spell.dbc → game_spell/game_spell_effect (see spell.rs) talents: bool, // with --dbc: import TalentTab.dbc + Talent.dbc → game_talent_tab/game_talent (see talent.rs) pub(crate) only: Vec, // with --spells: import ONLY these spell ids ADDITIVELY (surgical @@ -1054,14 +1013,14 @@ pub(crate) struct Args { // offering list (a subset of --only); the importer auto-adds them to the allowlist. pub(crate) world_profile: Option, map: i64, - include_maps: Vec, // --include-map (repeatable, work-item 226): ADDITIONAL maps whose + include_maps: Vec, // rows import WHOLE-MAP (no --box/--radius/--exclude geometry — an instance map like Deadmines 36 // is small and self-contained; its scope IS the map id). The primary --map keeps its box scoping. // Same single-run clear+reload as the box slice — this is the "no additive two-pass path" answer // for instance maps (see importer/scripts/import-world.sh's ONE-BOX note). center: (f64, f64, f64), radius: f64, - // --include-creatures : FORCE-import these creature entries (template + spawns + quest + // --include-creatures : FORCE-import these creature entries (template + spawns + quest // START/END relations + vendor/loot/gossip) regardless of the --box/--exclude geometry — the // mechanism to recover out-of-box quest givers (e.g. a Westfall/Stormwind courier NPC whose // sibling giver is in-band). Implemented in the spawn loop: a row whose entry is force-included @@ -1075,9 +1034,9 @@ pub(crate) struct Args { db: String, server: String, pub(crate) apply: bool, - pub(crate) family: Option, // --family (work-item 216): reload ONE --dump family's + pub(crate) family: Option, // clear+reload instead of the full ETL. `None` = full run (byte-identical to pre-216 behavior). - pub(crate) source_sha: String, // --source-sha (work-item 216): the resolved cmangos + pub(crate) source_sha: String, // classic-db commit this dump came from. It is threaded into every `stamp_import_meta` call this // run makes. Empty string ("") when not given (no external provenance to record). pub(crate) eventai_profile: String, @@ -1178,7 +1137,7 @@ where } "--pack-out" => a.pack_out = Some(it.next().context("--pack-out needs an output dir")?), // Derive the operator's OWN spawn bounding box for --map from their dump instead of trusting a - // remembered rectangle (work-item 206: the Westfall widening needed a way to compute --box from + // remembered rectangle: the Westfall widening needed a way to compute --box from // real data, not from memory). Prints min/max X/Y/Z + spawn count and returns — writes nothing. "--print-extents" => a.print_extents = true, "--spells" => a.spells = true, @@ -1249,7 +1208,7 @@ where } "--include-map" => { legacy_spatial_args.push("--include-map"); - // Repeatable: a WHOLE additional map in the slice (work-item 226 — Deadmines, map 36). + // Repeatable: a WHOLE additional map in the slice (Deadmines, map 36). // Fail loudly on a malformed id so a typo can't silently skip the dungeon's content. let m: i64 = it .next() @@ -1313,7 +1272,7 @@ where "--server" => a.server = it.next().context("--server")?, "--apply" => a.apply = true, "--family" => { - // Reload ONE --dump family's clear+reload instead of the full ETL (work-item 216). + // Reload ONE --dump family's clear+reload instead of the full ETL. // Validated against the fixed FAMILIES set below (right here, not deferred) so a typo // bails loudly before any dump parsing work happens. let v = it.next().context("--family ")?; @@ -1432,7 +1391,7 @@ where if !a.include_creatures.is_empty() && a.dump.is_none() { bail!("--include-creatures is only valid with --dump (it force-imports cmangos creature rows)"); } - // `--family` covers `--dump` families ONLY (work-item 216) — terrain/dbc/spells are separate + // `--family` covers `--dump` families ONLY, terrain/dbc/spells are separate // invocations with no family concept of their own. if a.family.is_some() && a.dump.is_none() { bail!("--family is only valid with --dump (it names one of the cmangos-dump ETL families)"); @@ -1519,7 +1478,7 @@ pub(crate) fn call_reducer_args(args: &Args, reducer: &str, values: &[&str]) -> Ok(()) } -/// Stamp `family`'s import provenance via the `stamp_import_meta` reducer (work-item 216) — called +/// Stamp `family`'s import provenance via the `stamp_import_meta` reducer, called /// once per family that ACTUALLY ran, after the apply succeeds. Unlike `call_reducer`'s single /// packed-string payload (the spawn/gameobject batch shape), `stamp_import_meta` takes 4 separate /// positional args, so each rides its own CLI argument — the same multi-arg `spacetime call` @@ -1555,7 +1514,7 @@ pub(crate) fn stamp_family( Ok(()) } -/// sha256 hex digest of `bytes` (work-item 216: `--source-sha`/provenance). This crate's `Cargo.lock` +/// sha256 hex digest of `bytes` (`--source-sha`/provenance). This crate's `Cargo.lock` /// carries no `sha2` dependency, so — mirroring this importer's existing convention of shelling out to /// `spacetime` for reducer calls rather than adding a client SDK dependency — this shells out to the /// system `sha256sum` instead of adding one. Feeds the dump bytes on stdin (`sha256sum -` reads @@ -1589,7 +1548,7 @@ pub(crate) fn sha256_hex(bytes: &[u8]) -> Result { pub(crate) fn read_dump(path: &str) -> Result { let bytes = std::fs::read(path).with_context(|| format!("read {path}"))?; // mysqldumps are latin1/utf8-ish and may carry stray non-UTF8 bytes; decode lossily on BOTH paths - // (read_to_string would hard-fail the .gz path on the first bad byte — the plain path never did). + // (read_to_string would hard-fail the.gz path on the first bad byte, the plain path never did). let raw = if path.ends_with(".gz") { let mut v = Vec::new(); GzDecoder::new(&bytes[..]) @@ -1694,7 +1653,7 @@ fn build_start_position_sql(dump: &str) -> Vec { stmts } -/// Clear+reload SQL for `game_graveyard_zone` from cmangos `game_graveyard_zone` (work-item 209): the +/// Clear+reload SQL for `game_graveyard_zone` from cmangos `game_graveyard_zone` : the /// zone (+ optional faction restriction) each imported graveyard (`game_graveyard`, loaded separately /// from `WorldSafeLocs.dbc` by `dbc.rs::graveyard_sql`) serves. `module/src/world.rs`'s /// `graveyard::resolve_graveyard` reads this via the `by_zone` index to prefer a zone-linked @@ -1726,7 +1685,7 @@ fn build_graveyard_zone_sql(dump: &str) -> Vec { stmts } -/// Clear+reload SQL for `game_areatrigger_teleport` from cmangos `areatrigger_teleport` (work-item 225): +/// Clear+reload SQL for `game_areatrigger_teleport` from cmangos `areatrigger_teleport` : /// dungeon entrance/exit portals — trigger id → target map + destination position. `id` here is the /// SAME `AreaTrigger.dbc` trigger id `game_area_trigger` (209, `dbc.rs::area_trigger_sql`) carries as /// its primary key: 209 imports the trigger's GEOMETRY (position/shape, from the client DBC), this @@ -1767,7 +1726,7 @@ fn build_areatrigger_teleport_sql(dump: &str) -> (Vec, usize) { (stmts, n) } -/// Count the distinct `(race, class)` combos among rows already formatted as `"(id,race,class,...)"` +/// Count the distinct `(race, class)` combos among rows already formatted as `"(id,race,class...)"` /// SQL tuples — shared coverage-print helper for the three `playercreateinfo_*` builders below (each /// row string always starts `(id,race,class,`, so a light textual split is enough; no need to re-parse /// the source dump rows a second time). @@ -1778,7 +1737,7 @@ fn distinct_race_class_combos(rows: &[(u32, u32)]) -> usize { combos.len() } -/// Clear+reload SQL for `game_createinfo_spell` from cmangos `playercreateinfo_spell` (work-item 212). +/// Clear+reload SQL for `game_createinfo_spell` from cmangos `playercreateinfo_spell`. /// **FLIPS THE SOURCE FROM SEED TO IMPORT**: `module/src/seed.rs::seed_createinfo_spells` only inserts /// the hand-authored `CREATEINFO_KIT` rows if the table is EMPTY (the `game_start_position` / Talent.dbc /// (207) precedent — an importer clear+reload REPLACES whatever is there, seed or a prior import; it @@ -1818,7 +1777,7 @@ fn build_createinfo_spell_sql(dump: &str) -> Vec { stmts } -/// Clear+reload SQL for `game_createinfo_action` from cmangos `playercreateinfo_action` (work-item 212) +/// Clear+reload SQL for `game_createinfo_action` from cmangos `playercreateinfo_action` /// — the vanilla default action-bar layout per (race, class), read by `module/src/action_bar.rs`'s /// `grant_createinfo_actions` at character creation (copied into `game_player_action`) and, when a /// character has none (pre-import), by the gateway's login synth fallback @@ -1858,7 +1817,7 @@ fn build_createinfo_action_sql(dump: &str) -> Vec { stmts } -/// `game_start_item.id` floor for `playercreateinfo_item` EXTRAS (work-item 212) — a disjoint range +/// `game_start_item.id` floor for `playercreateinfo_item` EXTRAS, a disjoint range /// from `dbc.rs::start_item_sql`'s own densely-assigned-from-1 CharStartOutfit ids, so the two writers' /// clear+reload DELETEs never touch each other's rows regardless of which pass runs (the canonical /// `importer/scripts/import-world.sh` runs the `--dump` pass THEN the standalone `--dbc` pass — without a @@ -1867,7 +1826,7 @@ fn build_createinfo_action_sql(dump: &str) -> Vec { /// hundred rows across every race/class/gender combo). const CREATEINFO_ITEM_ID_FLOOR: u64 = 1_000_000; -/// Clear+reload SQL for the `playercreateinfo_item` EXTRAS into `game_start_item` (work-item 212) — the +/// Clear+reload SQL for the `playercreateinfo_item` EXTRAS into `game_start_item`, the /// dump's per-(race,class) starter items BEYOND `CharStartOutfit.dbc`'s outfit (today's sole source — /// see `dbc.rs::start_item_sql`). Deduped against the outfit's own (race_class, item) pairs when /// `--dbc` is also given (via `dbc::outfit_item_set`) so the SAME item for the SAME (race, class) is @@ -1929,7 +1888,7 @@ fn build_createinfo_item_sql(dump: &str, dbc_dir: Option<&str>) -> Result Result .iter() .filter_map(|r| { let spell_id: u32 = field(r, sc::SPELL_ID).parse().ok()?; - // Defense-in-depth (102 review finding, mirrors build_createinfo_item_sql's guard): a - // hypothetical spell_id-0 row would slip past the `WHERE spell_id > 0` DELETE and PK-collide - // loudly on the NEXT reload — skip it here so DELETE and INSERT stay consistent. + if spell_id == 0 { return None; } @@ -2044,17 +2001,6 @@ fn client_spell_rank_rows( Ok(rows) } -/// Clear+reload SQL for `game_spell_learn` from cmangos `spell_learn_spell` (work-item 102, reduced -/// scope) — auto-taught dependents (e.g. a weapon-skill spell that also grants a companion passive). -/// `module/src/spell/spellbook.rs::learn_spell_with_dependents` reads this via `parent_spell` (the -/// `by_parent` index) from the trainer-buy grant path only (see that fn's doc). `Active` is PARSED -/// (named in `sls::ACTIVE`, kept as an explicit column read rather than silently skipped) but NOT used -/// to filter rows this slice — cmangos classic-db ships every row Active=1 in practice, and gating on -/// it would need a documented "what does Active=0 mean here" decision this reduced scope doesn't need -/// to make; a later item can add the filter once verified against a real dump. `id` is assigned -/// densely from 1 (the `#[auto_inc]` PK, like `game_trainer_spell`/`game_createinfo_spell` above — the -/// importer supplies explicit ids for a direct SQL INSERT rather than relying on live auto-assignment). -/// No Timestamp → plain SQL. /// /// Also synthesizes the two armor-proficiency wrapper→passive rows (verified against ClassicDB 1.12): /// Plate Mail wrapper `PLATE_TRAINER_SPELL_ID` (7109) → passive `PLATE_PASSIVE_SPELL_ID` (750), and @@ -2123,8 +2069,8 @@ fn build_spell_learn_sql(dump: &str) -> Vec { /// not silently disarm them (the same rule `importer/src/spell.rs` applies to `game_spell` itself). fn build_spell_proc_event_sql(dump: &str) -> Vec { let rows: Vec = parse_table(dump, "spell_proc_event") - .iter() - .filter_map(|r| { +.iter() +.filter_map(|r| { // A row from a schema that split `SpellFamilyMask` into three columns would map every // field past it onto the wrong meaning. Drop a row whose width does not match instead. if r.len() != spe::COLUMNS { @@ -2151,7 +2097,7 @@ fn build_spell_proc_event_sql(dump: &str) -> Vec { "({spell_id},{proc_flags},{proc_ex},{school_mask},{family_name},{family_flags},{ppm_rate},{custom_chance},{icd_ms})" )) }) - .collect(); +.collect(); let mut stmts = vec![format!( "DELETE FROM game_spell_proc_event WHERE spell_id < {SYNTHETIC_PROC_EVENT_FLOOR}" )]; @@ -2185,7 +2131,7 @@ fn null_to_empty(s: &str) -> &str { /// One npc_text slot: `(male, female, probability)`. Empty strings + probability 0.0 = unused slot. type NpcTextSlot = (String, String, f32); -/// Parse ALL 8 weighted npc_text slots from a raw dump row (work-item 217 — the pre-217 importer only +/// Parse ALL 8 weighted npc_text slots from a raw dump row, the pre-217 importer only /// kept the first non-empty slot; the client does its own weighted pick over the full 8, see /// `gateway::codec::build_npc_text_update`). Handles both schema vintages `npc_text_first_nonempty` /// already distinguished: @@ -2337,7 +2283,7 @@ fn resolve_gossip_option_text( /// Reclassify a gossip option's `action` by its resolved TEXT, for the handful of cmangos rows whose /// real behavior is gated in C++ code at `GossipHello`, not by the dump's `OptionType` column — so /// the column the importer copies through unchanged (see `gossip_option`'s doc) doesn't carry it. -/// Confirmed empirically (#516): every "I wish to unlearn my talents." row across the live dump +/// Confirmed empirically : every "I wish to unlearn my talents." row across the live dump /// (38/38) imports with `OptionType=1` (`gossip_option::GOSSIP`), indistinguishable from a plain /// gossip line, so `filtered_gossip_options`' level-10 gate has nothing to key on without this pass. /// Falls through to the raw `action` for every other row (the importer-reclassify-by-name pattern — @@ -2600,7 +2546,7 @@ struct QuestEtl { reward_spells: Vec, reward_choices: Vec, relations: Vec, - // GAMEOBJECT giver relations (work-item 041: GO 68 "Wanted Poster" starts q176 Wanted: Hogger, + // GAMEOBJECT giver relations: GO 68 "Wanted Poster" starts q176 Wanted: Hogger, // GO 55/56 "Lost Guards" corpses drive q37/q45/q71) — the `game_gameobject_quest` twin of `relations`. go_relations: Vec, // Reward mail (T3): one row per in-scope quest that sends a letter at turn-in, from either @@ -2613,7 +2559,7 @@ struct QuestEtl { // Collect-objective ReqItemId entries — unioned into the item-template load so every collect-quest // item is guaranteed a template even if item_template ever returns to a referenced subset. req_item_entries: std::collections::HashSet, - // Work-item 194 coverage counters: how many imported quests carry a non-zero next_quest_id + // coverage counters: how many imported quests carry a non-zero next_quest_id // (forward-chained) / limit_time (timed). Reported by the caller's eprintln! coverage line and // backing `FLOOR_QUESTS_CHAINED` in importer/scripts/import-manifest.sh. chained_count: usize, @@ -2646,7 +2592,7 @@ fn parse_ref_pools(dump: &str) -> std::collections::HashMap 0.0 { @@ -2921,7 +2862,7 @@ fn build_quests( }; let starts = local_relations("creature_questrelation", local_creatures); let ends = local_relations("creature_involvedrelation", local_creatures); - // GAMEOBJECT giver relations (work-item 041): GO 68 "Wanted Poster" starts q176 Wanted: Hogger + // GAMEOBJECT giver relations : GO 68 "Wanted Poster" starts q176 Wanted: Hogger // (which has NO creature start giver at all — unobtainable without this), and GO 55/56 "Lost // Guards" corpses drive the q37/q45/q71 chain. Scoped to GOs we actually spawn (`local_gameobjects`, // populated from the geographic gameobject-spawn loop), exactly like the creature relations above. @@ -3019,7 +2960,7 @@ fn build_quests( let req_races: u32 = field(&row, qt::REQUIRED_RACES).parse().unwrap_or(0); let req_classes: u32 = field(&row, qt::REQUIRED_CLASSES).parse().unwrap_or(0); let zone_or_sort: i64 = field(&row, qt::ZONE_OR_SORT).parse().unwrap_or(0); - // Reputation rewards (#13): 2 of the 5 cmangos RewRepFaction/Value slots (most Elwynn quests use + // Reputation rewards : 2 of the 5 cmangos RewRepFaction/Value slots (most Elwynn quests use // ≤1). Faction id stays as-is (0 = none); the value keeps its sign (vanilla values are positive, // but a rep LOSS is representable). Deliberate simplification: 2 slots — bump to 5 if a // quest needs >2 factions. @@ -3032,7 +2973,7 @@ fn build_quests( // COLLECT objective. A dump count of 0 means 1 (cmangos treats 0 as 1; the module floors at 1). let src_item: u64 = field(&row, qt::SRC_ITEM_ID).parse().unwrap_or(0); let src_item_count: u32 = field(&row, qt::SRC_ITEM_COUNT).parse().unwrap_or(0); - // Forward chains + timed quests (work-item 194): NextQuestInChain (0 = no successor; clamp + // Forward chains + timed quests : NextQuestInChain (0 = no successor; clamp // negative to 0 like prev_quest above) and LimitTime seconds (0 = untimed; cmangos never emits // a negative LimitTime, but clamp defensively the same way every other numeric column here does). let next_quest_raw: i64 = field(&row, qt::NEXT_QUEST_IN_CHAIN).parse().unwrap_or(0); @@ -3113,7 +3054,7 @@ fn build_quests( } } } - // Collect objectives (parity #4): up to 4 ReqItemId slots → "hold N of item X". kind 1 = + // Collect objectives (parity): up to 4 ReqItemId slots → "hold N of item X". kind 1 = // COLLECT_ITEM, target_entry = the item entry; the module checks a LIVE inventory count and // consumes them on turn-in. Continues the same dense obj_index space as the kill loop above, so // a quest with BOTH (kill + collect) sizes CharacterQuest.counts correctly. @@ -3265,7 +3206,7 @@ fn build_quests( emit_relations(&starts, 0); emit_relations(&ends, 1); - // Same shape, `game_gameobject_quest` table (work-item 041). A SEPARATE counter (`gq_id`) — this + // Same shape, `game_gameobject_quest` table. A SEPARATE counter (`gq_id`), this // table's ids are independent of `game_creature_quest`'s. let mut go_quest_rows: Vec = Vec::new(); let mut gq_id: u64 = 1; @@ -3370,7 +3311,7 @@ fn build_items_and_loot( vendor_id += 1; } - // 2.6) npc_vendor_template indirection (work-item 247): 11 spawned vendors (Corina Steele, + // 2.6) npc_vendor_template indirection : 11 spawned vendors (Corina Steele, // Brog Hamfist, …) have ZERO direct npc_vendor rows — cmangos serves their goods via // creature_template.VendorTemplateId → npc_vendor_template. Flatten that into the same // per-creature game_npc_vendor rows (our schema deliberately has no template indirection). @@ -3411,22 +3352,6 @@ fn build_items_and_loot( } } - // 3) item_template rows — the FULL vanilla item set (importer P4). Every item gets a template so - // loot, vendors, quest rewards, AND spell-created items (Mage conjure / quest CreateItem) all - // resolve — an unreferenced item is harmless reference data, like mangoS loading the whole table. - // (Was a referenced-only subset.) entry 0 is skipped; `item_ids` (the referenced set built above) - // is now used only to flag a DANGLING reference — a loot/vendor/quest item with NO item_template - // row, which would break that loot/turn-in — so it surfaces rather than silently dropping. - // ON-USE SPELL OVERRIDE (#387, finishing the spellid_1 migration `module/src/items/rules.rs` named as - // the end state): the curated alpha consumables + the Hearthstone whose on-use cast is OUR synthetic - // spell (50110-50119, seeded in `module/src/seed.rs`), not whatever the real vanilla dump's - // SpellId_1/SpellTrigger_1 says — those real ids either name spells outside our curated `game_spell` - // set, or (Spiced Wolf Meat, a crafted Cooking product) don't exist in vanilla at all. Applied AFTER - // the raw dump read below so the curated id always wins; spelltrigger_1 is forced to 0 (on-use) - // alongside it. Moved here from the now-retired `items::ops::USE_EFFECTS` shadow-map, byte-for-byte, - // plus the two entries that map ONLY here: the Hearthstone (6948, whose recall used to be a hardcoded - // item-entry special case in `apply_item_use`) and Roasted Boar Meat (2681, a level-1 food — - // deliberately routed to the SAME plain HoT as Tough Jerky/Bread, no Well-Fed buff at level 1). const USE_SPELL_OVERRIDE: &[(u64, u32)] = &[ (118, 50110), // Minor Healing Potion -> Minor Healing (instant E_HEAL) (1251, 50111), // Linen Bandage -> Linen Bandage (channeled A_PERIODIC_HEAL HoT) @@ -3453,7 +3378,7 @@ fn build_items_and_loot( let mut item_rows: Vec = Vec::new(); let mut emitted: HashSet = HashSet::new(); - // Work-item 213 coverage counters: how many items carry a nonzero resistance (any of the 6 + // coverage counters: how many items carry a nonzero resistance (any of the 6 // schools) and how many carry a nonzero startquest link — printed after the loop so a re-import // makes the new columns' reach visible without a manual SQL scan. let mut resist_nonzero_count: u64 = 0; @@ -3493,7 +3418,7 @@ fn build_items_and_loot( let class_v: u8 = field(&row, it::CLASS).parse().unwrap_or(0); let subclass_v: u8 = field(&row, it::SUBCLASS).parse().unwrap_or(0); let is_drink = is_drink_consumable(class_v, subclass_v, field(&row, it::NAME)); - // The 6 resistance schools (work-item 213) — data plumbing only, no consumer reads these yet. + // The 6 resistance schools, data plumbing only, no consumer reads these yet. let holy: i32 = field(&row, it::HOLY_RES).parse().unwrap_or(0); let fire: i32 = field(&row, it::FIRE_RES).parse().unwrap_or(0); let nature: i32 = field(&row, it::NATURE_RES).parse().unwrap_or(0); @@ -3503,7 +3428,7 @@ fn build_items_and_loot( if holy != 0 || fire != 0 || nature != 0 || frost != 0 || shadow != 0 || arcane != 0 { resist_nonzero_count += 1; } - // Spell slots 3-5 (work-item 213) — completes the 191 proc engine's item half; data plumbing + // Spell slots 3-5, completes the 191 proc engine's item half; data plumbing // only, same id+trigger shape as slots 1-2 above. let sp3: u32 = field(&row, it::SPELLID_3).parse().unwrap_or(0); let spt3: u8 = field(&row, it::SPELLTRIGGER_3).parse().unwrap_or(0); @@ -3511,7 +3436,7 @@ fn build_items_and_loot( let spt4: u8 = field(&row, it::SPELLTRIGGER_4).parse().unwrap_or(0); let sp5: u32 = field(&row, it::SPELLID_5).parse().unwrap_or(0); let spt5: u8 = field(&row, it::SPELLTRIGGER_5).parse().unwrap_or(0); - // Skill/reputation gates (work-item 213) — mail/plate proficiency + the 195 item half. + // Skill/reputation gates, mail/plate proficiency + the 195 item half. let req_skill: u32 = field(&row, it::REQUIRED_SKILL).parse().unwrap_or(0); let req_skill_rank: u32 = field(&row, it::REQUIRED_SKILL_RANK).parse().unwrap_or(0); let req_rep_faction: u32 = field(&row, it::REQUIRED_REPUTATION_FACTION) @@ -3520,7 +3445,7 @@ fn build_items_and_loot( let req_rep_rank: u32 = field(&row, it::REQUIRED_REPUTATION_RANK) .parse() .unwrap_or(0); - // maxcount/flags/page_text/startquest/bag_family (work-item 213) — data plumbing only. + // maxcount/flags/page_text/startquest/bag_family, data plumbing only. let max_count: u32 = field(&row, it::MAX_COUNT).parse().unwrap_or(0); let item_flags: u32 = field(&row, it::FLAGS).parse().unwrap_or(0); let page_text: u32 = field(&row, it::PAGE_TEXT).parse().unwrap_or(0); @@ -3532,7 +3457,7 @@ fn build_items_and_loot( // BuyCount (080): floor at 1 — a 0 in the dump (or a parse miss) must not sell zero items. let buy_count: u32 = field(&row, it::BUY_COUNT).parse().unwrap_or(1).max(1); let food_type: u8 = field(&row, it::FOOD_TYPE).parse().unwrap_or(0); - // ON-USE SPELL OVERRIDE (#387): a curated entry's spellid_1/spelltrigger_1 is OUR synthetic + // ON-USE SPELL OVERRIDE : a curated entry's spellid_1/spelltrigger_1 is OUR synthetic // on-use cast, forced trigger 0 — see `USE_SPELL_OVERRIDE`'s doc above. Every other item keeps // its raw dump value untouched (baseline-safe: the vast majority of the vanilla item catalog is // unaffected). @@ -3555,7 +3480,7 @@ fn build_items_and_loot( // ContainerSlots — how many bag slots this item provides (0 for non-bags); col 24 in // the cmangos 1.12 item_template schema, immediately after Stackable(23). cslots = field(&row, it::CONTAINER_SLOTS).parse::().unwrap_or(0), - // Bonding (work-item 127): 0=NoBind,1=BoP,2=BoE,3=BoU,4/5=QuestItem — mapped straight + // Bonding : 0=NoBind,1=BoP,2=BoE,3=BoU,4/5=QuestItem, mapped straight // through to the client's "Binds when picked up/equipped" tooltip line. bonding = field(&row, it::BONDING).parse::().unwrap_or(0), class = field(&row, it::CLASS), @@ -3682,7 +3607,7 @@ fn build_mail_loot( /// that `spacetime sql` rejects (400) — row-count chunking alone never splits it. No-op for empty `rows`. /// Data-row count across a family's generated statement list: DELETEs contribute 0; each /// `push_insert`-shaped INSERT contributes its VALUES tuple count (`),(` separators + 1). -/// Feeds the provenance stamps (work-item 216) for families whose builders return statements +/// Feeds the provenance stamps for families whose builders return statements /// rather than row vectors — statement `.len()` would under-report by the batch factor. /// Only sound for numeric-payload tables (a text value containing `),(` would over-count); /// the "globals" builders (stats/positions/graveyards/createinfo) are all numeric. @@ -3765,7 +3690,7 @@ pub(crate) fn row_in_slice(args: &Args, map: i64, x: f64, y: f64, z: f64) -> boo /// What `--print-extents` derived from the dump. `anchor` is the in-slice spawn CLOSEST to the /// rectangle's centre — a REAL cmangos ground point, which is exactly what `--center` needs (the /// terrain importer's interpolate self-check compares the ADT height at `--center` against the Z the -/// operator supplied, and the nav importer requires `--center` to rasterize walkable). Issue #24: +/// operator supplied, and the nav importer requires `--center` to rasterize walkable).: /// deriving a second continent's box also means deriving a second continent's centre, and the map-0 /// default (Northshire) is off-slice there. struct Extents { @@ -3776,7 +3701,7 @@ struct Extents { } /// Pure spawn-bbox computation for `--print-extents`: scan every `creature` row on `map` — optionally -/// pre-filtered to a CANDIDATE `--box` rectangle (issue #24: a whole continent's extents are the +/// pre-filtered to a CANDIDATE `--box` rectangle: a whole continent's extents are the /// continent, so deriving a starting-zone box is an iterate-and-narrow loop — pass a rough rectangle, /// read back the real spawn extents and count inside it) — returning `None` if no row matched /// (nothing to report). Split out from `print_extents` so the corner math is unit-tested against a @@ -3824,7 +3749,7 @@ fn compute_extents(dump: &str, map: i64, bbox: Option<(f64, f64, f64, f64)>) -> } /// The cmangos `playercreateinfo` start positions ON `map`, as `(race, class, zone, x, y, z)` — the -/// canonical starting-zone ANCHORS of a continent (issue #24: "where does a Kalimdor box go?" is +/// canonical starting-zone ANCHORS of a continent: "where does a Kalimdor box go?" is /// answered by the dump's own Valley of Trials / Camp Narache / Shadowglen rows, not from memory). /// Pure so it is unit-testable without stdout; printed by `print_extents` below. fn start_positions_on_map(dump: &str, map: i64) -> Vec<(u32, u32, u32, f64, f64, f64)> { @@ -3847,13 +3772,6 @@ fn start_positions_on_map(dump: &str, map: i64) -> Vec<(u32, u32, u32, f64, f64, .collect() } -/// `--print-extents`: print the operator's OWN `--map` spawn bounding box derived from THEIR dump -/// (min/max X/Y/Z + spawn count), plus a suggested `--box`/`--center` and the map's start positions, -/// and return — writes NOTHING (no SQL, no reducer call). This is how an operator derives a new -/// zone's exact rectangle (e.g. Westfall) instead of trusting a remembered/guessed number (work-item -/// 206), and how a SECOND CONTINENT's starting-zone box gets derived rather than guessed (issue #24): -/// run it bare for the whole-continent extents, pick a starting zone from the printed start -/// positions, then re-run with a rough `--box` around it and read back the real in-box extents/count. fn print_extents(dump: &str, map: i64, bbox: Option<(f64, f64, f64, f64)>) -> Result<()> { let scope = match bbox { Some((x0, x1, y0, y1)) => format!(" inside --box {x0:.0},{x1:.0},{y0:.0},{y1:.0}"), @@ -3915,15 +3833,15 @@ pub(crate) fn run_sql_statements(args: &Args, stmts: &[String], label: &str) -> Ok(()) } -/// The fixed set of `--family` values this importer recognizes (work-item 216) — one per independent +/// The fixed set of `--family` values this importer recognizes, one per independent /// clear+reload block the `--dump` ETL below already builds (the inventory `import-world.sh`'s header /// comment describes: "the ETL is clear+reload PER FAMILY"). `--family` covers THESE families only — /// terrain (`--terrain`), client DBCs (`--dbc`), and Spell.dbc (`--dbc --spells`) are separate /// invocations with no family concept of their own. "globals" is the P1/P3/212 slice (start /// positions, the class/level stat curve, graveyard-zone links, createinfo spells/actions/items, -/// work-item 225's areatrigger-teleport dungeon portals) — not box-scoped, but still its own +/// Area Trigger dungeon portals, not box-scoped, but still its own /// clear+reload, so `--family quests` reloads QUESTS ALONE (not -/// quests-plus-globals). "spellmeta" (work-item 102, reduced scope) is the same "global, not box-scoped, +/// quests-plus-globals). "spellmeta" (reduced scope) is the same "global, not box-scoped, /// own clear+reload" shape as "globals" — kept as its OWN family rather than folded into "globals" /// because nothing else reads its output (compute-gateable too, unlike "globals"' entangled siblings — /// see `build_dump_plan`'s gate). @@ -4006,7 +3924,7 @@ fn template_is_importable(row: &[String]) -> bool { field(row, ct::UNIT_FLAGS).parse::().unwrap_or(0) & UNIT_FLAG_NOT_SELECTABLE == 0 } -/// Build the full `--dump` ETL plan (work-item 216's `--family` split lives entirely in here, via +/// Build the full `--dump` ETL plan's `--family` split lives entirely in here, via /// `family_active` gates around each family's DELETE+INSERT block): parses the cmangos dump, filters /// the content slice, maps every family's rows, and returns the ready-to-apply plan. Pure w.r.t. the /// outside world (no `spacetime call`/SQL execution) — `main` does that against the returned @@ -4144,7 +4062,7 @@ fn build_dump_plan( &creature_skin_ids, &chest_loot_ids_used, ); - // Gossip menus + NPC text + menu options (work-item 217): map each in-box gossip NPC's + // Gossip menus + NPC text + menu options : map each in-box gossip NPC's // creature_entry to the npc_text id shown in SMSG_GOSSIP_MESSAGE, import the resolved greeting // strings (all 8 weighted slots, not just the first), and import its clickable menu options // (vendor/innkeeper/trainer/plain-gossip, quest-status conditions folded). Four-table clear+reload. @@ -4340,7 +4258,7 @@ fn collect_creature_spawns(dump: &str, scope: &WorldImportScope) -> Vec = Vec::new(); let mut pool_resolved = 0u32; @@ -4377,7 +4295,7 @@ fn collect_creature_spawns(dump: &str, scope: &WorldImportScope) -> Vec = std::collections::HashMap::new(); - // entry → PickpocketLootId / SkinLootId (work-item 210) — same "0 = no table" convention as LootId. + // entry → PickpocketLootId / SkinLootId, same "0 = no table" convention as LootId. let mut creature_pickpocket_ids: std::collections::HashMap = std::collections::HashMap::new(); let mut creature_skin_ids: std::collections::HashMap = @@ -4586,7 +4504,7 @@ fn build_creature_templates( if loot_id > 0 { creature_loot_ids.insert(entry, loot_id); } - // Loot-family completeness (work-item 210): PickpocketLootId collapses onto creature_entry + // Loot-family completeness : PickpocketLootId collapses onto creature_entry // directly (like LootId, above); SkinLootId is recorded per-entry too but stays keyed on the // loot-id ITSELF in game_skinning_loot (several creatures share one level-banded table). let pickpocket_loot_id: u64 = field(&row, ct::PICKPOCKET_LOOT_ID).parse().unwrap_or(0); @@ -4638,7 +4556,7 @@ fn build_creature_templates( // game_creature_template column order (see module/src/creatures/spawn.rs). The trailing // `aggro_range` (proximity aggro) has no cmangos source column, so emit 0 (passive — // retaliate-only, the column default); aggression tuning is a separate content pass. - // `pickpocket_loot_id`/`skin_loot_id` (work-item 210) are END-appended after `armor`. + // `pickpocket_loot_id`/`skin_loot_id` are END-appended after `armor`. // Trainer service columns, END-appended after `skin_loot_id`. Both parse to `u8` and fall to // 0 on an absent or unparseable cell; for `trainer_class` that 0 is the fail-open "serves // everyone" value. Not `num_or_zero`: it only rewrites an EMPTY cell and would splice a @@ -4811,7 +4729,7 @@ fn build_packed_spawn_payload( .iter() .filter(|s| !excluded_entries.contains(&s.1)) .map(|(db_guid, entry, x, y, z, o, mt, respawn_secs, map)| { - // `map` is the ROW's own map (work-item 226) — an --include-map spawn (Deadmines 36) + // `map` is the ROW's own map, an --include-map spawn (Deadmines 36) // must land on ITS map, not the primary --map's. The dump Z rides through UNALTERED on // every path: the importer NEVER snaps spawn Z to terrain (there is no ground_z here at // all — runtime creature legs snap with a keep-current-Z fallback, module terrain.rs), @@ -4860,7 +4778,7 @@ fn build_waypoint_rows( direct_wp_guids.insert(db_guid); wp_id += 1; } - // 4b) creature_movement_template (work-item 226): ENTRY-keyed paths, expanded onto every + // 4b) creature_movement_template : ENTRY-keyed paths, expanded onto every // in-slice MovementType=2 spawn of that entry that has NO direct rows (direct always wins). // Waypoint Zs — like spawn Zs — ride through VERBATIM (no terrain snap exists in the importer; // cmangos waypoints are floor-correct, which Map 36 needs while ground_z remains absent). @@ -4924,12 +4842,12 @@ fn build_waypoint_rows( /// The gameobject spawns the World Import Scope keeps, packed for the `import_gameobjects` reducer, /// with the classified template metadata they resolved against. fn collect_gameobject_spawns(dump: &str, scope: &WorldImportScope) -> GameobjectSpawns { - // Gameobjects (work-item 211 widened import): EVERY in-scope cmangos `gameobject_template` type + // Gameobjects (widened import): EVERY in-scope cmangos `gameobject_template` type // now imports (template + spawn) — previously only an allowlisted subset (GOOBER/curated GATHER/ // curated CHEST/QUESTGIVER) did. `classify_go_type` decides the stored `type_id` (or drops the row // — the TYPE-25 COLLISION GUARD); `go_template_row` then builds the per-type SQL row. The spawns // load via the import_gameobjects reducer (game_gameobject has a Timestamp); the packed spawn row - // carries an `initial_state` field (work-item 211) so a DOOR/BUTTON can spawn already-open. + // carries an `initial_state` field so a DOOR/BUTTON can spawn already-open. let go_tmpls = parse_table(dump, "gameobject_template"); let mut dropped_type25: Vec = Vec::new(); let go_meta: std::collections::HashMap = go_tmpls @@ -4990,7 +4908,7 @@ fn collect_gameobject_spawns(dump: &str, scope: &WorldImportScope) -> Gameobject .filter(|(_, m)| m.stored_type == GO_GOOBER) .map(|(&e, _)| e) .collect(); - // Spawn-membership test: work-item 211 widens this to EVERY classified template (previously a + // Spawn-membership test: widens this to EVERY classified template (previously a // union of 4 allowlists) — the whole point of the widened import. `go_meta.get(&id)` below IS the // membership test (a dropped/unclassified entry has no `go_meta` row). let mut go_packed_rows: Vec = Vec::new(); @@ -5078,7 +4996,7 @@ fn gameobject_template_rows(spawns: &GameobjectSpawns) -> GameobjectTemplateRows // Template rows for the GOs actually spawned, split by type (`go_template_row`) — a SQL INSERT must // name EVERY column (#[default] is NOT applied on INSERT, only on migration — slice-5 data-loss // lesson). Also builds the per-type coverage histogram + the CHEST lootId set (widened to EVERY - // imported chest, not a curated allowlist — work-item 210's scoping now spans the whole live set). + // imported chest, not a curated allowlist and its scoping now spans the whole live set). let mut go_template_rows: Vec = Vec::new(); let mut go_trap_rows: Vec = Vec::new(); let mut go_meeting_stone_rows: Vec = Vec::new(); @@ -5122,7 +5040,7 @@ fn gameobject_template_rows(spawns: &GameobjectSpawns) -> GameobjectTemplateRows chest_loot_ids_used.push(loot_id); } } - // Per-type coverage (work-item 211): loud so an operator sees exactly what a box pulled in — which + // Per-type coverage : loud so an operator sees exactly what a box pulled in, which // types are LIVE (dispatched by `apply_use_gameobject`) vs INERT (template+spawn only, `use` no-ops). const GO_LIVE_TYPE_NAMES: &[(u8, &str)] = &[ (GO_DOOR, "DOOR"), @@ -5223,7 +5141,7 @@ fn build_trainer_spell_rows( // the Apprentice→Artisan tiers (real Spell.dbc id → SMSG_LEARNED_SPELL resolves). cost 0 (the // gossip-learn is free in vanilla). Skip a tier whose spell id already collides with a class/recipe // row for THIS trainer (so a trainer can't double-insert the same spell_id). Every column is NAMED - // (slice-5 lesson). The cap (work-item 208) comes from the REAL client's SkillTiers.dbc data when + // (slice-5 lesson). The cap comes from the REAL client's SkillTiers.dbc data when // --dbc was given (already parity-checked equal to LEGACY_TIER_CAPS above); without --dbc there's no // client data to resolve, so it falls back to LEGACY_TIER_CAPS directly (documented fallback). let mut synthesized = 0usize; @@ -5263,7 +5181,7 @@ fn build_loot_family_rows( creature_skin_ids: &std::collections::HashMap, chest_loot_ids_used: &[u32], ) -> LootFamilyRows { - // Loot-family completeness (work-item 210): pickpocket / skinning / gameobject(chest) / fishing. + // Loot-family completeness : pickpocket / skinning / gameobject(chest) / fishing. // `refs` (the reference_loot_template pool map) is parsed ONCE here and shared by all four — // `parse_creature_drops` above already parsed its own copy for the creature family; a second parse // for these four is the same low-cost tradeoff this importer already accepts (content-slice sized). @@ -5684,8 +5602,8 @@ fn push_creature_behaviour_statements( /// The two global (not box-scoped) families. Returns their row counts for the provenance stamps. fn push_global_statements(args: &Args, dump: &str, stmts: &mut Vec) -> Result<(u64, u64)> { // P3: the class/level stat curve + P1: all-(race,class) start positions + 209: graveyard-zone - // links (all global, plain SQL — not box-scoped) + work-item 212's createinfo spells/actions/items - // + work-item 225's areatrigger-teleport dungeon portals. + // links (all global, plain SQL, not box-scoped) + Character creation spells, actions, and items + // + Area Trigger dungeon portals. // Family "globals": not box-scoped, but its own clear+reload — gated too, so `--family quests` // reloads QUESTS ALONE (not quests-plus-globals). No other family's builder reads these outputs, so // gating their compute too (not just the push) is safe — unlike the entangled quests/items pair. @@ -5727,7 +5645,7 @@ fn push_global_statements(args: &Args, dump: &str, stmts: &mut Vec) -> R stmts.extend(areatrigger_teleport_sql); } - // Spell metadata (family "spellmeta", work-item 102 reduced scope): rank chain, auto-learn + // Spell metadata (family "spellmeta", reduced scope): rank chain, auto-learn // dependents, and the proc overlay. Not box-scoped (global, like "globals"), plain SQL (no // Timestamp). Every one of them is map-independent, so they land on the same destinations the // spell catalogue does. Nothing else in this function reads any builder's output, so — unlike the @@ -5782,7 +5700,7 @@ fn family_stamps( eventai, .. } = content; - // Provenance stamps (work-item 216): one entry per family that ACTUALLY had its block pushed + // Provenance stamps : one entry per family that ACTUALLY had its block pushed // above — mirrors exactly the `family_active` gates this function used, so a stamp only ever // corresponds to data this run's plan actually carries. let mut stamps: Vec<(&'static str, u64)> = Vec::new(); @@ -5873,7 +5791,7 @@ fn main() -> Result<()> { return nav::run(&args); } - // `--vmap` → the #520/#521 exact vmap triangle extract + pack + import (see vmap.rs). + // `--vmap` → the/ exact vmap triangle extract + pack + import (see vmap.rs). if args.vmap.is_some() || args.vmap_status { return vmap::run(&args); } @@ -5919,7 +5837,7 @@ fn main() -> Result<()> { // `--print-extents` → derive the operator's OWN spawn bbox for --map and STOP (write nothing). // This is how the operator computes the exact --box rectangle for a new zone instead of eyeballing - // it from memory (work-item 206 — the Westfall widening needed this the ETL didn't have before). + // it from memory (the Westfall widening needed this the ETL didn't have before). if args.print_extents { return print_extents(&dump, args.map, args.bbox); } @@ -5939,7 +5857,7 @@ fn main() -> Result<()> { None => None, }; - // Optional profession tier-cap resolution (work-item 208): SkillRaceClassInfo.dbc + SkillTiers.dbc + // Optional profession tier-cap resolution : SkillRaceClassInfo.dbc + SkillTiers.dbc // give the REAL Apprentice→Artisan caps per profession skill line — checked immediately against // `LEGACY_TIER_CAPS` (`assert_profession_tier_parity`, bailing loudly on any disagreement) before the // trainer-synthesis pass below ever runs. Without --dbc there's no client data to check against, so @@ -5985,9 +5903,9 @@ fn main() -> Result<()> { args.server, args.db, plan.eventai_definition_batches - .first() - .and_then(|batch| batch.lines().next()) - .unwrap_or("") +.first() +.and_then(|batch| batch.lines().next()) +.unwrap_or("") ); println!( "-- load the typed EventAI relay catalogue atomically through the reducer.\n e.g. spacetime call -s {} {} import_creature_ai_relay_definitions ''", @@ -6081,7 +5999,7 @@ fn main() -> Result<()> { } eprintln!("import applied."); - // Provenance stamps (work-item 216): `plan.stamps` already carries exactly the families whose + // Provenance stamps : `plan.stamps` already carries exactly the families whose // block was pushed (see `build_dump_plan`) — stamp each one now that the apply above succeeded. let file_hash = sha256_hex(dump.as_bytes())?; for (family, row_count) in &plan.stamps { @@ -6152,17 +6070,17 @@ mod tests { } } - // ---- work-item 211: widened GO import — classifier, per-type row builder, TYPE-25 guard ---- + // ----: widened GO import, classifier, per-type row builder, TYPE-25 guard ---- #[test] fn classify_go_type_remaps_gather_and_passes_everything_else_through_identity() { // A GATHER_NODES entry (real cmangos type 3, a "locked CHEST" in cmangos' own model) remaps to // the synthetic 25 marker — this is the ONLY entry point that ever produces stored type 25. assert_eq!(classify_go_type(1731, 3), Some(GO_GATHER)); // Copper Vein - // A type-3 entry NOT in GATHER_NODES is a real CHEST now (work-item 211 widened import — used + // A type-3 entry NOT in GATHER_NODES is a real CHEST now widened import, used // to require the TREASURE_CHESTS allowlist; ANY chest imports today). assert_eq!(classify_go_type(999_999, 3), Some(GO_CHEST)); - assert_eq!(classify_go_type(106318, 3), Some(GO_CHEST)); // Battered Chest (formerly allowlisted) + assert_eq!(classify_go_type(106318, 3), Some(GO_CHEST)); // Battered Chest // DOOR/BUTTON/QUESTGIVER/GOOBER pass through identity (this repo's ids match cmangos' here). assert_eq!(classify_go_type(1, 0), Some(GO_DOOR)); assert_eq!(classify_go_type(2, 1), Some(GO_BUTTON)); @@ -6302,7 +6220,7 @@ mod tests { #[test] fn cr_columns_map_spawn_time_and_movement_type() { - // work-item 047: creature (spawn) row layout — guid,id,map,spawnMask,x,y,z,o, + // creature (spawn) row layout, guid,id,map,spawnMask,x,y,z,o, // spawntimesecsmin,spawntimesecsmax,spawndist,MovementType — verify the column CONSTANTS // actually land on the cmangos min-respawn-seconds and MovementType fields, not neighbors // (a silent off-by-one here would misread every imported creature's respawn timer). @@ -6328,7 +6246,7 @@ mod tests { #[test] fn compute_extents_derives_min_max_xyz_and_count_for_the_target_map_only() { - // work-item 206: --print-extents derives the operator's real --box from THEIR dump. Three + // --print-extents derives the operator's real --box from THEIR dump. Three // spawns on map 0 (min/max should span them) + one on map 1 (must be excluded). let dump = "x INSERT INTO `creature` VALUES \ (1,100,0,1,-11000.0,1000.0,10.0,0,300,300,0,0),\ @@ -6341,7 +6259,7 @@ mod tests { assert_eq!(e.max, (-9000.0, 2000.0, 50.0)); // A map with no spawns at all reports None (nothing to derive a box from). assert!(compute_extents(dump, 7, None).is_none()); - // Issue #24: map 1's extents are derived from the MAP-1 rows alone — a second continent's box + // map 1's extents are derived from the MAP-1 rows alone, a second continent's box // comes out of the same one code path, never out of the map-0 numbers. let k = compute_extents(dump, 1, None).expect("map 1 has one spawn"); assert_eq!( @@ -6352,7 +6270,7 @@ mod tests { #[test] fn compute_extents_narrows_to_a_candidate_box_and_anchors_center_on_a_real_spawn() { - // Issue #24: a whole continent's extents ARE the continent, so deriving a starting-zone box is + // a whole continent's extents ARE the continent, so deriving a starting-zone box is // iterate-and-narrow — pass a rough rectangle and read back the true in-box extents + count. // The anchor is the in-box spawn nearest the rectangle centre: a REAL cmangos ground point, so // it is always a valid `--center` for the terrain interpolate / nav walkability self-checks. @@ -6377,7 +6295,7 @@ mod tests { #[test] fn start_positions_are_listed_for_the_requested_map_only() { - // Issue #24: the printed starting-zone anchors come from the dump's OWN playercreateinfo, which + // the printed starting-zone anchors come from the dump's OWN playercreateinfo, which // is how a Kalimdor box gets drawn around Valley of Trials instead of around a remembered // coordinate. Human(1) is map 0; Orc(2)/Tauren(6) are map 1. let dump = "x INSERT INTO `playercreateinfo` VALUES \ @@ -6410,7 +6328,7 @@ mod tests { #[test] fn build_graveyard_zone_sql_parses_id_ghost_zone_and_faction() { - // work-item 209: cmangos game_graveyard_zone columns are (id, ghost_zone, faction) — id is + // cmangos game_graveyard_zone columns are (id, ghost_zone, faction), id is // the world_safe_locs/game_graveyard id (safe_loc_id here), ghost_zone the zone it serves, // faction 0 = both / else a team-faction id. Northshire+Goldshire+Eastvale serve zone 12 // (Alliance-only, 469); Sentinel Hill serves zone 40 (also 469). @@ -6434,7 +6352,7 @@ mod tests { #[test] fn build_areatrigger_teleport_sql_maps_columns_by_tuple_position() { - // work-item 225: cmangos areatrigger_teleport columns are (id, name, target_map, + // cmangos areatrigger_teleport columns are (id, name, target_map, // target_position_x, target_position_y, target_position_z, target_orientation) — see the `at` // module's `[V]` doc comment. Two rows with DISTINCT values in every column (no accidental // digit reuse across fields) so a column-index typo/transposition would fail this test, not @@ -6492,7 +6410,7 @@ mod tests { assert!(!is_drink_consumable(0, 2, "Elixir of Water Walking")); // elixir (subclass 2) excluded } - // ---- LOOT-FAMILY COMPLETENESS (work-item 210) ---- + // ---- LOOT-FAMILY COMPLETENESS ---- /// `resolve_ref_pool` flattens a 2-DEEP reference chain (pool 1 → item A direct + a `-ref` to pool 2 /// → item B) into ONE flat list, and a CYCLE (pool 3 → pool 4 → pool 3) terminates instead of @@ -6567,7 +6485,7 @@ mod tests { ); } - /// Work-item 213: the dropped item_template columns (resistances, extra spell slots, skill/rep + /// the dropped item_template columns (resistances, extra spell slots, skill/rep /// gates, maxcount/flags/page_text/startquest/bag_family) parse from their VERIFIED cmangos /// column indices into the right positions of the emitted `game_item_template` row, appended /// after `bonding` in struct order. Builds a synthetic 121-column row (indices 0..=120, reaching @@ -6636,7 +6554,7 @@ mod tests { } /// A template with no resistance/spell-3-5/skill/rep/maxcount/flags/page_text/startquest/ - /// bag_family data at all keeps every work-item 213 column at 0 — the additive-migration + /// bag_family data at all keeps every column at 0, the additive-migration /// baseline (byte-identical to the pre-213 shape for an ordinary existing item like the starter /// Worn Shortsword). Built the same index-constant-driven way as the row above, so a real /// no-frills weapon row round-trips with every new column zeroed. @@ -6716,7 +6634,7 @@ mod tests { assert_eq!(normalize_item_mask("-1", ALL_PLAYABLE_RACE_MASK), 0xff); } - // --- work-item 212: playercreateinfo_* fixture tests ------------------------------------------ + // ---: playercreateinfo_* fixture tests ------------------------------------------ #[test] fn build_createinfo_spell_sql_parses_race_class_spell_and_wipes_wholesale() { @@ -6804,7 +6722,7 @@ mod tests { assert!(!insert.contains(",0)"), "item 0 must not import: {insert}"); } - // --- work-item 102 (reduced scope): spell_chain / spell_learn_spell fixture parse/shape --------- + // --- (reduced scope): spell_chain / spell_learn_spell fixture parse/shape --------- #[test] fn build_spell_chain_sql_parses_rank_chain_and_wipes_wholesale() { @@ -6950,8 +6868,6 @@ mod tests { #[test] fn build_spell_learn_sql_parses_parent_and_dependent_ids_densely_from_one() { - // entry(parent), SpellID(dependent), Active — Active is parsed but NOT used to filter (see the - // builder's doc): an Active=0 row still imports, identically to an Active=1 row. let dump = "x INSERT INTO `spell_learn_spell` VALUES (196,197,1),(196,198,0); y"; let stmts = build_spell_learn_sql(dump); assert_eq!(stmts[0], "DELETE FROM game_spell_learn WHERE id > 0"); @@ -7026,7 +6942,7 @@ mod tests { #[test] fn family_spellmeta_filter_produces_only_spellmeta_family_stmts() { - // `--family spellmeta` (work-item 102, reduced scope, mirroring the 216 `--family` gate + // `--family spellmeta`, reduced scope, mirroring the 216 `--family` gate // parity pattern): the plan must carry ONLY the three spellmeta blocks — no other family's // statements — even on a dump that also carries an in-box quest fixture. let dump = format!( @@ -7113,7 +7029,7 @@ mod tests { assert_eq!(plan.stamps, vec![("spellmeta", 5u64)], "{:?}", plan.stamps); } - // --- work-item 217: gossip completeness (menu options, multi-slot npc_text, conditions) -------- + // ---: gossip completeness (menu options, multi-slot npc_text, conditions) -------- /// Build a `creature_template` INSERT tuple wide enough to reach `ct::GOSSIP_MENU_ID` (col 77): /// entry at col 0, gossip_menu_id at col 77, everything else `0`. @@ -7259,7 +7175,7 @@ mod tests { let entries: std::collections::HashSet = [entry].into_iter().collect(); let etl = build_gossip_sql(&dump, &entries); assert_eq!(etl.option_rows.len(), 2); - // (row_id,entry,option_index,icon,text,...) — option_index 0 must land on the LOWER cmangos id. + // (row_id,entry,option_index,icon,text...), option_index 0 must land on the LOWER cmangos id. assert!( etl.option_rows[0].contains(&format!("{entry},0,0,'First.'")), "{}", @@ -7519,7 +7435,7 @@ mod tests { ); } - // --- work-item 216: import pipeline tooling (--family, --source-sha, provenance stamps) -------- + // ---: import pipeline tooling (--family, --source-sha, provenance stamps) -------- #[test] fn sha256_hex_matches_known_test_vectors() { @@ -7538,7 +7454,7 @@ mod tests { #[test] fn families_fixed_set_accepts_every_named_family_and_rejects_unknown() { // Mirrors `parse_args`'s `--family` validation exactly (`FAMILIES.contains(&v.as_str())`) — - // pins the fixed set work-item 216 promised: covers --dump families only (terrain/dbc/spells + // pins the fixed set promised: covers --dump families only (terrain/dbc/spells // are separate invocations, not in this list). for name in [ "creatures", @@ -7807,14 +7723,14 @@ mod tests { assert!(format!("{removed:#}").contains("scope is empty")); } - // ---- Work-item 226: --include-map (the Deadmines map-36 slice) ------------------------------ + // ----: --include-map (the Deadmines map-36 slice) ------------------------------ #[test] fn row_in_slice_keeps_box_geometry_for_the_primary_map_and_whole_map_for_included() { let mut args = test_args(); args.bbox = Some((-9000.0, -8900.0, -200.0, -100.0)); args.include_maps = vec![36]; - // Primary map: the box still gates, exactly as before --include-map existed. + // Primary-map spawns remain bounded by the requested box. assert!( row_in_slice(&args, 0, -8949.95, -132.493, 83.5), "in-box map-0 row imports" @@ -7988,7 +7904,7 @@ mod tests { #[test] fn the_map_fence_drops_a_force_included_entry_that_spawns_on_a_foreign_map() { - // Issue #24 (Phase B): --include-creatures matches on ENTRY, so before the map fence a + // --include-creatures matches on ENTRY, so before the map fence a // force-listed giver's spawn on ANOTHER map rode into the slice — i.e. a Kalimdor shard's ETL // could import an Elwynn spawn (or vice versa) purely because an entry was force-listed. No // flag combination may cross the map boundary; the fence is the gate, not script hygiene. @@ -8005,7 +7921,7 @@ mod tests { "the in-box map-1 row is kept" ); // Symmetric: on the canonical map-0 run a force-listed entry's map-1 spawn is fenced out too, - // while its in-corridor map-0 spawn still bypasses the box exactly as work-item 206 needs. + // while its in-corridor map-0 spawn still bypasses the box exactly as needs. let mut m0 = test_args(); m0.bbox = Some((-11400.0, -8000.0, -3100.0, 2000.0)); m0.include_creatures = vec![344]; @@ -8126,7 +8042,7 @@ mod tests { fn include_map_spawns_import_whole_map_with_their_own_map_and_verbatim_z() { // One in-box map-0 creature, one map-36 creature far outside the box with a WMO-interior Z, // one map-1 creature (NOT included — must be dropped), plus a map-36 DOOR gameobject. - // Pins the three work-item 226 invariants: (a) --include-map imports the whole extra map, + // Pins the three invariants: (a) --include-map imports the whole extra map, // (b) each packed row carries its OWN map id (not args.map), and (c) the dump Z rides // through UNALTERED. There is no ground-z snap in the import path, and Map 36 does not stage // single-layer terrain, so the spawn's cmangos Z remains its authored height. @@ -8188,9 +8104,6 @@ mod tests { #[test] fn gameobject_spawn_quaternion_rides_through_the_packed_row_verbatim() { - // Issue #515: the importer used to drop rotation_0..3 entirely (only `orientation` carried). - // A bench with a real, non-trivial cmangos spawn quaternion must reach the packed - // guid,id,map,x,y,z,o,initial_state,rot0,rot1,rot2,rot3 row byte-verbatim, in that order. let dump = "INSERT INTO `gameobject_template` VALUES (446,0,259,'Wooden Bench',0,0,0,0,0,0,0,0); \ INSERT INTO `gameobject` VALUES \ @@ -8907,12 +8820,6 @@ mod tests { assert!(requirements.contains("(1,500)"), "{requirements}"); } - /// Like `quest_template_row` but also stamps `qt::NEXT_QUEST_IN_CHAIN` / `qt::LIMIT_TIME` - /// (work-item 194) — the chained/timed quest fixture. Also stamps `qt::REW_MONEY_MAX_LEVEL` with a - /// distinctive non-zero value (777) — distinctive so the emitted `reward_money_max_level` column - /// can be pinned by VALUE (not just "a comma follows"), and non-zero so a bug that always emits the - /// `unwrap_or(0)` default would still be caught (issue #58 review: the prior end-anchored assertion - /// only asserted the trailing comma, never the value it carried). fn quest_template_row_chained_timed( entry: u64, title: &str, @@ -9519,7 +9426,7 @@ mod tests { #[test] fn family_quests_filter_produces_only_quest_family_stmts() { - // `--family quests` (work-item 216): the plan must carry ONLY the quest tables' DELETE+INSERT + // `--family quests` : the plan must carry ONLY the quest tables' DELETE+INSERT // — no creature/item/loot/gossip/gameobject/trainer/cast/globals statements — even though every // family's prerequisite parsing still ran (the quest giver relation needed the creature entry // to resolve, per `build_quests`' doc). @@ -9579,7 +9486,7 @@ mod tests { assert_eq!(plan.stamps, vec![("quests", 1u64)], "{:?}", plan.stamps); } - /// Work-item 194: `next_quest_id`/`limit_time` thread through the FULL import (dump row → parsed + /// `next_quest_id`/`limit_time` thread through the FULL import (dump row → parsed /// column → the emitted `game_quest_template` INSERT tuple), not just `build_quests`' own struct — /// extends the quests-family fixture with a chained+timed quest. #[test] @@ -9714,8 +9621,6 @@ mod tests { #[test] fn insert_row_count_counts_values_tuples_not_statements() { - // The provenance-stamp fix (216 review finding): a 3-row batched INSERT is 3 rows, a DELETE - // is 0 — statement count (2) would under-report. let mut stmts = vec!["DELETE FROM game_x WHERE id >= 0".to_string()]; push_insert( &mut stmts, @@ -9730,13 +9635,6 @@ mod tests { #[test] fn family_absent_full_run_is_the_union_of_every_single_family_run() { - // Full-run SELF-CONSISTENCY (work-item 216): with `--family` ABSENT every family is active, - // so the full run's `stmts` must be a SUPERSET of every individual family's own filtered run - // over the SAME dump, and re-running must be deterministic. HONESTY NOTE: this is NOT a - // parity test against the pre-refactor `main()` body (no old code remains to call) — it - // cross-checks `build_dump_plan` against itself, so a bug introduced identically into the - // full-run and per-family paths would pass. The byte-parity of the 216 refactor itself was - // established by a manual statement-by-statement diff during review, not by this test. let dump = tiny_quest_dump(101, 501); let mut full_args = test_args(); full_args.family = None; @@ -9781,7 +9679,7 @@ mod tests { ); } - // --- #223: bad input / missing files / partial datasets --------------------------------------- + // ---: bad input / missing files / partial datasets --------------------------------------- // The importer's whole job is to eat an operator-supplied file we have never seen. Every test // below pins what happens when that file is WRONG — missing, truncated, corrupt, or half a // schema — because the failure mode we cannot afford is a silent one: an import that "succeeds" @@ -9810,9 +9708,6 @@ mod tests { } } - /// Value tuples in ONE `INSERT INTO t (cols) VALUES (..),(..)` statement (same counting rule as - /// `insert_row_count`, per-statement — `matches('(')` would over-count by one for the column - /// list). Used to assert that the bad rows of a partial dataset really were dropped. fn tuple_count(insert: &str) -> usize { insert.matches("),(").count() + 1 } @@ -9822,7 +9717,7 @@ mod tests { // A mistyped `--dump` is the single most common operator error, and the run that follows is // destructive (clear+reload). The error MUST name the path the operator actually passed — // a bare "No such file or directory" leaves them guessing which of --dump/--dbc/--terrain - // was wrong. Both the plain and the .gz branch route through the same `read` context, so + // was wrong. Both the plain and the.gz branch route through the same `read` context, so // both are pinned: read_dump reads the bytes BEFORE it looks at the extension. for path in [ "/nonexistent/lyracore/importer/path.sql", @@ -9859,7 +9754,7 @@ mod tests { // DELIBERATE design decision, pinned so nobody "fixes" it into `read_to_string`: real // cmangos dumps are latin1/utf8-ish and carry stray non-UTF8 bytes inside creature/item // names. A strict decode would abort the WHOLE import over one bad byte in one name (and - // `read_to_string` cannot even be used on the .gz path). We decode lossily on both paths: + // `read_to_string` cannot even be used on the.gz path). We decode lossily on both paths: // the offending byte becomes U+FFFD and every valid row around it still imports. let f = ScratchFile::new("latin1.sql", b"INSERT INTO `t` VALUES (1,'Caf\xE9 Ale');"); let text = read_dump(f.path()).expect("a stray non-UTF8 byte must not fail the import"); @@ -9913,7 +9808,6 @@ mod tests { parse_table(spawns, "creature_template").is_empty(), "`creature_template` must NOT match `creature` rows" ); - // ...and each still finds its own table in the same dump. let both = format!("{templates}\n{spawns}"); assert_eq!(parse_table(&both, "creature_template").len(), 1); assert_eq!(parse_table(&both, "creature").len(), 1); diff --git a/importer/src/nav.rs b/importer/src/nav.rs index b11f1e39d..179b0a533 100644 --- a/importer/src/nav.rs +++ b/importer/src/nav.rs @@ -1,5 +1,5 @@ -//! `--nav ` — work-item 241: rasterize ADT terrain + WMO/M2 collision -//! geometry into per-cell nav blobs → `game_nav_chunk` (decision #8: grid tables, no +//! `--nav ` uses rasterize ADT terrain + WMO/M2 collision +//! geometry into per-cell nav blobs → `game_nav_chunk` (decision: grid tables, no //! vmap/Detour runtime). Same licensing firewall as `--terrain`. Honors `--map` + `--box`. //! //! Pipeline: parse every ADT tile in the box (heights via terrain.rs's `collect_cells` — the @@ -45,7 +45,7 @@ pub(crate) type Tri = [[f32; 3]; 3]; // Geometry extraction // --------------------------------------------------------------------------------------------- -/// One WMO collision triangle plus the group it came from — `vmap.rs` (#520) packs this +/// One WMO collision triangle plus the group it came from, `vmap.rs` packs this /// per-triangle metadata (source class + group id + MOGP flags); `nav.rs`'s rasterizer only /// needs `.tri`. pub(crate) struct WmoTri { @@ -475,7 +475,7 @@ fn rasterize_cell(cell: &crate::terrain::CellRow, tris: &[VmapTri]) -> Option) -> Result<()> { Ok(()) } -/// Recursively copy `from` into `to` (used to install an addon folder). fn copy_dir(from: &Path, to: &Path) -> Result<()> { fs::create_dir_all(to)?; for entry in fs::read_dir(from)? { diff --git a/importer/src/package_delta.rs b/importer/src/package_delta.rs index 86bdfbfa5..25489edc6 100644 --- a/importer/src/package_delta.rs +++ b/importer/src/package_delta.rs @@ -1,35 +1,3 @@ -//! The Package Delta stage: read every enabled Package's generated artifacts, report the plan they -//! form, and hand the whole plan to the module in one reducer call. -//! -//! A base import replaces a whole Import Family, so a Package's claims have to be reapplied after -//! it. This stage runs last, once the family's base rows are back and its provenance is stamped. -//! -//! # Where the artifacts live -//! -//! `//data/.generated/*.json`. The path sits INSIDE the Package -//! folder on purpose: `lyracore packages enable`/`disable` moves that folder between `packages/` and -//! `.lyracore/packages-disabled/`, so the artifacts move with it. What this stage can see IS the -//! enabled set — there is no second list to disagree with the Package Inventory. -//! -//! # Check and apply -//! -//! The importer's own convention decides which: without `--apply` this prints the plan and writes -//! nothing, with `--apply` it calls the reducer. The plan printed by a check is the plan an apply -//! sends. -//! -//! # Routing a spatial claim -//! -//! Most claimed tables are global catalogues every Shard loads whole. Two are SPATIAL — a creature -//! spawn and a gameobject spawn — and each names the map it sits on in its own key. A spatial claim -//! belongs to this Shard exactly when this run's World Import Scope owns that map, which is the same -//! fence the base import filters its own spawns through (`creature_row_kept_in_scope`). Routing -//! reads that scope and nothing else: there is no second concept, and no per-Package Shard list to -//! disagree with it. -//! -//! A claim for another Shard's map is DROPPED from this plan, not refused. Refusing it would make a -//! realm of several Shards impossible to import, because every Shard would choke on the maps it does -//! not own. The report names what was routed away, so a check still accounts for every claim. - use std::fs; use std::path::{Path, PathBuf}; diff --git a/importer/src/spell.rs b/importer/src/spell.rs index ba9a9b71d..e855d276d 100644 --- a/importer/src/spell.rs +++ b/importer/src/spell.rs @@ -61,12 +61,12 @@ const E_SUMMON_PET: u8 = 0x15; // summon a persistent pet creature owned by the const E_HEAL_MAX_HEALTH: u8 = 0x16; // heal the target to FULL max health (Lay on Hands); mapped from the raw vanilla HealMaxHealth effect (67), split out of E_HEAL because its base_points is ~0 (the magnitude is "fill to max", not a flat N) (lockstep with module taxonomy) const E_TAME_CREATURE: u8 = 0x20; // completed Hunter tame; raw vanilla TameCreature effect (55), lockstep with module taxonomy const E_FEED_PET: u8 = 0x21; // feed a Hunter pet from the explicit item target; raw effect 101 -const E_POWER_BURN: u8 = 0x19; // drain target mana into damage (Mana Burn); mapped from the raw vanilla PowerBurn effect (62), p1 = EffectMultipleValue*100 basis-points (lockstep with module taxonomy, work-items 117) +const E_POWER_BURN: u8 = 0x19; const E_BLINK: u8 = 0x1A; // teleport the caster ~20yd FORWARD along its facing (Mage Blink, 116); reclassified BY NAME from the dead SCRIPT teleport effect (lockstep with module taxonomy) const E_PERSISTENT_AREA: u8 = 0x1B; // ground-AoE (118, Consecration): spawns a fixed-position game_ground_area whose tick damages hostiles inside; reclassified BY NAME from the ground A_PERIODIC_DAMAGE effect (lockstep with module taxonomy) -const E_OPEN_LOCK: u8 = 0x1D; // OPEN LOCK (Pick Lock 1804, work-item 119): gateway-intercepted like E_FISH (0x1C)/E_ENCHANT_ITEM — a CMSG_CAST_SPELL for a spell carrying this kind routes to the `pick_lock` reducer (unlock a locked GameObject, gated on the caster's Lockpicking 633 skill). Mapped from the raw vanilla OpenLock (33) / OpenLockItem (59) effects (lockstep with module taxonomy). 0x1E is reserved for a future E_SUMMON_PORTAL — do NOT reuse. +const E_OPEN_LOCK: u8 = 0x1D; const E_DUEL: u8 = 0x22; // Duel (raw effect 83): p0 is the duel-flag gameobject template entry -const E_DISENCHANT: u8 = 0x18; // DISENCHANT (real Disenchant 13262, work-item 282): gateway-intercepted, routed to the disenchant reducer by kind. Mapped from raw vanilla effect 99 (SPELL_EFFECT_DISENCHANT); no params (the module validates + yields dust by item). Lockstep with the module taxonomy (module/src/spell/taxonomy.rs E_DISENCHANT). +const E_DISENCHANT: u8 = 0x18; const E_DISMOUNT: u8 = 0x23; // remove the target's active land mount (Dazed's mount-removal half): translated from a raw DISPEL_MECHANIC effect (108) whose misc value names the mount mechanic (21) — see `dismount_effect_kind` below. No params. Lockstep with module taxonomy. const E_SUMMON_HOSTILE: u8 = 0x24; // temporary ownerless summon; p0 = creature entry, p1 = required spell focus, header duration = lifetime const E_DISTRACT: u8 = 0x25; // turn idle enemy Creatures near the ground point toward it; raw effect 69, amount = seconds (lockstep with module taxonomy) @@ -174,7 +174,7 @@ const SPEED_MOUNTED: i32 = 3; // ModIncreaseMountedSpeed family; T4 folds this s // STANCE/FORM ids (vanilla SpellShapeshiftForm.dbc indices) — the value the ModShapeshift effect's // `effect_misc_value` carries (so a stance/form spell arrives as A_FLAG with p0 = the form id). Our // engine uses its OWN small 0-based stance id space; `form_to_stance` below is THE one form→stance -// mapping (work-item 156 widened it past the Warrior trio to the Druid combat forms), consumed by BOTH +// mapping (widened it past the Warrior trio to the Druid combat forms), consumed by BOTH // the E_SET_STANCE p0 remap (`stance_p0`) and the `Stances` usability-mask fold (`translate_stance_mask`) // so the two can never drift. The Spell.dbc `Stances` usability mask is a form-BIT mask // (`1 << (formId-1)`: bit0=Cat/bit4=Bear/bit7=DireBear/bit16=Battle/bit17=Defensive/bit18=Berserker); @@ -223,7 +223,7 @@ const SPELL_ATTR_REQ_OVERPOWER: u32 = 0x0020; // Overpower: castable only in the const SPELL_ATTR_REQ_REVENGE: u32 = 0x0040; // Revenge: castable only in the ~5s window after the caster DODGED/PARRIED/BLOCKED an incoming swing (Tier 2b react window) const SPELL_ATTR_CHANNELED: u32 = 0x0080; // CHANNELED (Arcane Missiles): the cast ticks a per-tick effect over duration_ms and breaks on action — set from the DBC AttributesEx1 CHANNELED bit (0x44), by-NAME fallback. Drives the A_PERIODIC_TRIGGER reclassify below const SPELL_ATTR_REQ_DAGGER: u32 = 0x0100; // Backstab: castable only with a DAGGER equipped in the main hand (lockstep with module/src/spell/taxonomy.rs) -const SPELL_ATTR_RANGED_AUTO_REPEAT: u32 = 0x0200; // Auto Shot / wand Shoot: an AUTO-REPEAT ranged attack, not a one-shot cast. The GATEWAY reads this bit (game_spell.cast_flags) to intercept CMSG_CAST_SPELL and arm the ranged swing loop, instead of a hardcoded `spell == 75 || 5019` id list (work-item 097). Set from the DBC AttributesEx2 AUTOREPEAT bit, by-NAME fallback. +const SPELL_ATTR_RANGED_AUTO_REPEAT: u32 = 0x0200; // vanilla `AttributesEx1` CHANNELED bits — IS_CHANNELLED (0x04) | CHANNELED_2 (0x40). cmangos: channeled = AttributesEx1 & 0x44. const ATTR_EX1_CHANNELED_MASK: u32 = 0x0000_0044; @@ -365,7 +365,7 @@ fn excludes_eventai_caster(attributes_ex1: u32) -> bool { /// ranged-weapon timer until stopped, not as a one-shot cast? Keyed on the DBC `AttributesEx2` AUTOREPEAT /// bit (`0x20`), with a by-NAME fallback for the two player abilities in case the bit is not cleanly /// readable on this client build. The GATEWAY (never a spell-id list) then routes CMSG_CAST_SPELL on the -/// resulting `SPELL_ATTR_RANGED_AUTO_REPEAT` cast_flags bit (work-item 097). +/// resulting `SPELL_ATTR_RANGED_AUTO_REPEAT` cast_flags bit. fn is_ranged_auto_repeat(attributes_ex2: u32, name: &str) -> bool { attributes_ex2 & ATTR_EX2_AUTOREPEAT != 0 || matches!(name, "Auto Shot" | "Shoot") } @@ -428,7 +428,7 @@ fn power_burn_ratio_bp(multiple: f32) -> i32 { /// graceful `E_SCRIPTED` no-op. IDs verified against `wow_world_base::vanilla::SpellEffect` (the /// `from_int` table): SchoolDamage=2, Heal=10, HealMaxHealth=67, Energize=30, Dispel=38, /// TriggerSpell=64, Threat=63/ThreatAll=91, weapon-damage family 17/58/121/31, Resurrect=18, -/// AddComboPoints=80, ResurrectNew=113, AttackMe=114 (work-item 101 — cross-checked against the +/// AddComboPoints=80, ResurrectNew=113, AttackMe=114, cross-checked against the /// vendored `wow_world_base-0.3.0` vanilla/tbc/wrath `SpellEffect::from_int` tables, all three eras /// agree on these four numeric ids). /// CONFIRMED 2026-07-14 against the real client Spell.dbc (curated-kit dry-run + targeted --only): @@ -446,21 +446,21 @@ fn instant_effect_to_kind(effect_id: i32) -> u8 { 38 => E_DISPEL, // Dispel 64 => E_TRIGGER, // TriggerSpell 63 | 91 => E_TAUNT, // Threat / ThreatAll - 114 => E_TAUNT, // AttackMe (work-item 101) — same force-aggro semantics as Threat/ThreatAll, just a distinct raw id; not known to occur in the curated 1-10 human kit (no Taunt/Mocking Blow/Challenging Shout id is in any IDS_* list), so this widens coverage for a FUTURE (non-Human or higher-level) import, not the curated kit today + 114 => E_TAUNT, 24 => E_CREATE_ITEM, // CreateItem (conjure / quest item) — p0 = item entry - 68 => E_INTERRUPT, // InterruptCast (Kick) — cancel the target's in-progress cast + 68 => E_INTERRUPT, // InterruptCast (Kick), cancel the target's in-progress cast 55 => E_TAME_CREATURE, // TameCreature — explicit wild target, no spell-id branch 101 => E_FEED_PET, // FeedPet — explicit item target is routed by the gateway/manual cast seam 56 => E_SUMMON_PET, // Summon (Summon Imp et al.) — p0 = the summoned creature entry (misc_value) - 62 => E_POWER_BURN, // PowerBurn (Priest Mana Burn) — p1 = EffectMultipleValue*100 (work-items 117) - 83 => E_DUEL, // Duel — p0 carries the duel-flag gameobject entry - 69 => E_DISTRACT, // Distract: the effect amount is the Distraction's length in seconds - 33 | 59 => E_OPEN_LOCK, // OpenLock (33) / OpenLockItem (59) — Pick Lock (work-item 119): gateway-intercepted, routed to the pick_lock reducer by kind (Pick Lock 1804 carries the raw OpenLock effect; the item-lock variant 59 covers a lockpick-on-item spell) - 99 => E_DISENCHANT, // Disenchant (13262, work-item 282): gateway-intercepted, routed to the disenchant reducer by kind — the AUTOLEARN enchanting ability. Was falling through to E_SCRIPTED (a no-op). - 80 => E_ADD_COMBO, // AddComboPoints (work-item 101) — the curated Rogue generators (Sinister Strike/Backstab/Gouge/Garrote) carry the generic Dummy effect in-kit and are rescued BY NAME in correct_script_effect_kind below, not via this raw id, so this arm is currently unexercised by the curated kit but correct for any spell that DOES carry the raw AddComboPoints effect + 62 => E_POWER_BURN, + 83 => E_DUEL, // Duel, p0 carries the duel-flag gameobject entry + 69 => E_DISTRACT, // Distract: the effect amount is the Distraction's length in seconds + 33 | 59 => E_OPEN_LOCK, + 99 => E_DISENCHANT, + 80 => E_ADD_COMBO, 94 => E_SELF_RESURRECT, // SelfResurrect (Soulstone Resurrection, Reincarnation): read by the Module's self-resurrection, never cast - 18 | 113 => E_RESURRECT, // Resurrect / ResurrectNew (work-item 101) — the curated kit's two resurrects (Priest Resurrection 2006, Paladin Redemption 7328) are ALSO rescued by name below; if either carries raw effect 18/113 in the real DBC (plausible — that is literally what the effect exists for) this arm now resolves them natively too, moving them out of `cov.unmapped_effect` even though the final kind was already E_RESURRECT via the name rescue either way — unverified without a client DBC dump, so [V] - _ => E_SCRIPTED, // remaining vanilla effects: queryable no-op + 18 | 113 => E_RESURRECT, + _ => E_SCRIPTED, // remaining vanilla effects: queryable no-op } } @@ -506,33 +506,17 @@ fn correct_script_effect_kind(name: &str, kind: u8) -> u8 { "Seal of Righteousness" => A_SEAL, // the inert A_FLAG marker IS a proc-on-swing holy seal "Stealth" => A_STEALTH, // the inert A_FLAG marker IS the stealth presence (creatures skip it; broken on action) "Retaliation" => A_RETALIATE, // the inert A_FLAG marker IS the free-counter-swing self-buff (any melee attacker gets swung back at) - // The stance/form-switch spells encode their stance as APPLY_AURA + ModShapeshift(form) → the - // inert A_FLAG marker (the form id in p0). Reclassify to E_SET_STANCE; the effect loop then - // remaps p0 from the form id to our 0-based stance id via `form_to_stance`. The cast handler - // writes WorldEntity.stance. This is the ONE place allowed to name the stance spells. Work-item - // 156 added the Druid combat forms (Bear 5487 → stance 3, Cat 768 → 4, Dire Bear 9634 → 5) — - // rank-less spells, so exact-name matching covers them; the non-combat forms (Aquatic/Travel/ - // Moonkin/Tree of Life) stay un-rescued (their marker stays the inert A_FLAG, the pre-156 shape). + "Battle Stance" | "Defensive Stance" | "Berserker Stance" | "Bear Form" | "Dire Bear Form" | "Cat Form" => E_SET_STANCE, "Judgement" => E_JUDGEMENT, // unleash the active seal // Rogue GENERATORS: the inert combo-point Dummy effect (-> E_SCRIPTED) builds a combo point. // Garrote's eff2 is the same combo Dummy (its eff1 is the A_PERIODIC_DAMAGE bleed, unchanged). - // (work-item 101: `instant_effect_to_kind` now maps the raw AddComboPoints effect id (80) to + // `instant_effect_to_kind` now maps the raw AddComboPoints effect id (80) to // E_ADD_COMBO natively too, but the in-kit generators carry the generic Dummy effect, not raw // 80, so this name rescue is still the ONLY path that reaches them — kept, not redundant here.) "Sinister Strike" | "Backstab" | "Gouge" | "Garrote" => E_ADD_COMBO, - // Every class's combat-rez shares the same raw Resurrect effect (18/113) that falls to - // E_SCRIPTED without this rescue — and E_SCRIPTED is a graceful-success no-op, so a missed - // name here makes the cast "succeed" while reviving nobody (the 176 review caught - // Redemption doing exactly that: the healer bot would tunnel a broken rez forever). - // (work-item 101: `instant_effect_to_kind` now maps raw Resurrect/ResurrectNew (18/113) to - // E_RESURRECT natively; per THIS comment's own prior claim that these spells "share the same - // raw Resurrect effect (18/113)", the raw-id arm likely already resolves Resurrection/Redemption - // before this rescue ever runs (the `kind != E_SCRIPTED` guard above short-circuits), making this - // arm belt-and-braces for those two — still load-bearing for Rebirth/Ancestral Spirit, which - // aren't Human-class spells and never ship through this curated kit. Harmless overlap either way; - // left in place. [V] — unconfirmed without a client DBC dump.) + "Resurrection" | "Redemption" | "Rebirth" | "Ancestral Spirit" => E_RESURRECT, "Pick Pocket" => E_PICKPOCKET, // the script effect grants creature copper without engaging // (Feint is handled above — its native Threat effect maps to E_TAUNT, not the E_SCRIPTED residue.) @@ -569,7 +553,7 @@ fn soulstone_option_spell(aura_spell_id: u32) -> Option { /// Translate the vanilla Spell.dbc `Stances` (ShapeshiftMask) bitmask → our 0-based stance usability bits. /// Vanilla stores bit `1 << (formId-1)` for each allowed form; every form `form_to_stance` maps (the -/// Warrior trio AND, since work-item 156, the Druid combat forms Bear/Cat/Dire Bear) folds onto our bit +/// Warrior trio AND, since, the Druid combat forms Bear/Cat/Dire Bear) folds onto our bit /// `1 << stance` — so a druid ability's Bear|DireBear requirement (vanilla 0x90) survives import as our /// 0x28 instead of being dropped. Bits for UNMAPPED forms (Aquatic/Travel/Tree/Ghoul/…) are still /// dropped, and a 0 mask stays 0 ("usable in any stance"). The result is a u8 (our six stance bits fit @@ -723,7 +707,7 @@ fn aura_mod_to_kind(aura: AuraMod) -> u8 { // the inert A_FLAG so the regen gate can read the magnitude without a spell-id or race check. ModRegenDuringCombat | ModHealthRegenInCombat => A_COMBAT_HEALTH_REGEN_PCT, - // Demon Skin/Armor's health-per-5 (work-item 024): aura 84 SPELL_AURA_MOD_REGEN + // Demon Skin/Armor's health-per-5 : aura 84 SPELL_AURA_MOD_REGEN // is a COMBAT-INDEPENDENT periodic heal tick (it heals a living target on a fixed period // whether or not it is in combat) — the same primitive already // wired for Renew/bandages/food. Reclassified from the inert A_FLAG marker onto A_PERIODIC_HEAL; @@ -812,10 +796,7 @@ fn is_wrapper_rank_trigger(kind: u8) -> bool { fn resolve_aura_params(kind: u8, aura: AuraMod, misc: u32) -> (i32, u8) { use AuraMod::*; match kind { - // Flat AND percent stat mods resolve the stat the SAME way — from the effect's misc value. -1 - // (u32::MAX) = all stats (0xFF, e.g. Blessing of Kings/Mark of the Wild); a specific value = that - // single stat (The Human Spirit's percent effect names Spirit, NOT all — the old blanket - // ModTotalStatPercentage→STAT_ALL force was the bug that made it +5% to every stat). [104] + // Flat and percentage modifiers both select the stat from misc. u32::MAX selects all. A_MOD_STAT | A_MOD_STAT_PCT => { let p0 = if misc == u32::MAX { STAT_ALL @@ -1098,7 +1079,7 @@ fn derive_spell_rows( continue; } - // --- reagents (game_spell_reagent, work-item 282) --- + // --- reagents (game_spell_reagent) --- // Spell.dbc carries up to 8 (Reagent, ReagentCount) pairs. Every real recipe's true mats // live here (and any reagent-consuming buff); the craft gate (module cast.rs) resolves by // this data instead of a hardcoded id list. Deterministic id (spell_id<<3)|slot → @@ -1692,7 +1673,7 @@ fn resolve_effect_kind( // Consecration → T_SELF would DoT the paladin himself. Reclassify BY NAME to E_PERSISTENT_AREA // (the Charge/Blink name-rescue precedent) so it spawns a game_ground_area whose own // tick_ground_areas damages hostiles inside. Consecration is caster-anchored; Flamestrike (262) - // is the first CLICKED-GROUND one — the 118 phase-2 dest plumbing (6067df1) anchors the area at + // is the first CLICKED-GROUND one, the 118 dest plumbing (6067df1) anchors the area at // the click when the cast carries a DEST_LOCATION block, so the same kind serves both. // Blizzard/Rain of Fire remain un-rescued (channeled patches — their channel/tick interplay is // its own follow-up; leaving them A_PERIODIC_DAMAGE keeps them out of the curated kit). @@ -1900,7 +1881,7 @@ fn assemble_spell_sql( // Trainer offerings (259 INVERSION): the --dump ETL's npc_trainer import is now the PRIMARY // source of class offerings (full per-class trees, the dump's real costs/reqlevels — an - // operator-local .import read like every other --dump field); this curated pass is the + // operator-local.import read like every other --dump field); this curated pass is the // OVERRIDE layer for the "specials" cmangos delivers outside npc_trainer (Consecration, // Flamestrike, Kick, stances, …). It therefore reloads ONLY ITS OWN reserved-id rows // (delete-by-id over the reserved span) and never touches the dump rows or the profession @@ -1941,16 +1922,6 @@ fn build_spell_sql( const CURATED_TRAINER_ID_BASE: u64 = 5_200_000; const CURATED_TRAINER_ID_SPAN: u64 = 500; -/// Floor of the synthetic/test fixture spell-id range seeded by `module/src/seed.rs` (Combat Insight -/// 50000, Minor Healing 50110) and `module/src/seed/fixtures.rs` (Test PW:Shield 50072, Test -/// Regeneration 50137) — NONE of these are re-created by any import script, so a wholesale -/// `game_spell` / `game_spell_effect` clear must never touch `spell_id >= SYNTHETIC_SPELL_ID_FLOOR`. -/// The ONE reducer-seeded spell BELOW the floor is Weakened Soul (6788, real vanilla id) — the -/// wholesale clear DOES wipe the seeded row, and the full import re-creates 6788 from its OWN real DBC -/// entry, which carries a genuine `A_IMMUNITY` (mechanic-shield) aura effect (confirmed for work-item -/// 122), so the reload is self-healing with no synthetic needed. The module side has no equivalent named constant (each fixture -/// hardcodes its own literal id); if one is ever added there, mirror the name -/// `SYNTHETIC_SPELL_ID_FLOOR` and keep both floors equal — a one-way hand-sync until then. const SYNTHETIC_SPELL_ID_FLOOR: u32 = 50_000; /// The DELETE half of the (clear+)reload, split out so the surgical-vs-wholesale choice is testable. @@ -1958,16 +1929,6 @@ const SYNTHETIC_SPELL_ID_FLOOR: u32 = 50_000; /// DELETE so the import is ADDITIVE + idempotent and never touches the curated seed / test fixtures. /// Per-id `WHERE x = N` (sorted, deterministic) avoids relying on SQL `IN(..)` support and keeps each /// statement planner-friendly; the allowlist is always a small curated kit. -/// -/// The wholesale branch guards `spell_id < SYNTHETIC_SPELL_ID_FLOOR` on BOTH tables so the synthetic -/// fixture spells (50000/50072/50110/50137, none re-created by any import script) survive a full-DBC -/// reload. `game_spell_effect` is filtered on its own `spell_id` COLUMN (btree `by_spell`, -/// `module/src/spell/tables.rs:65`) — NOT the packed `id` (`spell_id<<2|effect_index`) — a range filter -/// on the packed id would not track the fixture floor at all. Per danger-zones.md §2, a single-column -/// range filter on `spacetime sql` can wrongly return 0 rows; this guard NEEDS LIVE VERIFICATION (the -/// work-item's runbook) — it has NOT been run against a real node from this sandbox. Bounded blast -/// radius if it no-ops: `spell_id` is the PK, so the reload's INSERTs collide loudly instead of -/// corrupting silently. fn spell_delete_statements(only: &[u32]) -> Vec { if only.is_empty() { return vec![ @@ -2025,7 +1986,7 @@ fn kind_name(kind: u8) -> &'static str { E_JUDGEMENT => "E_JUDGEMENT", E_ADD_COMBO => "E_ADD_COMBO", E_FINISHER_DAMAGE => "E_FINISHER_DAMAGE", - E_RESURRECT => "E_RESURRECT", // was missing (work-item 101) — the histogram printed "?" for this kind even though it's been a real, dispatched kind since the by-name rescue landed + E_RESURRECT => "E_RESURRECT", E_PICKPOCKET => "E_PICKPOCKET", E_INTERRUPT => "E_INTERRUPT", E_REDUCE_THREAT => "E_REDUCE_THREAT", @@ -2073,9 +2034,7 @@ fn print_coverage(cov: &Coverage) { } }; println!("\n=== SPELL TAXONOMY COVERAGE REPORT ==="); - // BY DESIGN (work-item 100): a full (non --only) import pulls in the raid/other-class/PvP long - // tail, which has no Rust kind mapping yet (that's work-item 101) — so E_SCRIPTED% balloons on a - // full run vs. the curated kit. That's the intended end state, NOT a regression to "fix" here. + println!("Total spells imported: {}", cov.spells); println!("Total effect rows: {}", cov.effects); println!( @@ -2088,8 +2047,7 @@ fn print_coverage(cov: &Coverage) { cov.scripted, pct(cov.scripted, cov.effects) ); - // Procs woke up: every effect that landed natively on the two Proc kinds this ticket added (aura - // 42/43, no name rescue). An Operator reviews this count after an import to see what changed. + let proc_trigger = cov.by_kind.get(&A_PROC_TRIGGER).copied().unwrap_or(0); let proc_damage = cov.by_kind.get(&A_PROC_DAMAGE).copied().unwrap_or(0); println!( @@ -2197,7 +2155,7 @@ pub fn run_spells(data_dir: &str, args: &Args) -> Result<()> { " [additive allowlist]" }, ); - // Provenance stamp (work-item 216 convention, mirrored from the --dump loop's per-family + // Provenance stamp convention, mirrored from the --dump loop's per-family // stamp_family calls in main.rs::run_dump). file_hash is "" here: unlike the --dump path // (which hashes the whole SQL dump's bytes in one shot), the DBC chain is read row-by-row // through `read_table`'s parsed-struct API (dbc.rs) with no single raw byte buffer to hash @@ -3055,8 +3013,7 @@ mod tests { correct_script_effect_kind("Ancestral Spirit", E_SCRIPTED), E_RESURRECT ); // Shaman - // A same-shaped SCRIPT_EFFECT under any OTHER name stays the graceful no-op (the guard the - // 176 review flagged: a missed name here silently makes the cast succeed while reviving nobody). + assert_eq!( correct_script_effect_kind("Reincarnation", E_SCRIPTED), E_SCRIPTED @@ -3168,7 +3125,7 @@ mod tests { assert_eq!(stance_p0(E_SET_STANCE, 17), 0); // Battle Stance → stance 0 assert_eq!(stance_p0(E_SET_STANCE, 18), 1); // Defensive Stance → stance 1 assert_eq!(stance_p0(E_SET_STANCE, 19), 2); // Berserker Stance → stance 2 - // Druid combat forms (work-item 156): Bear Form 5487 carries ModShapeshift(5), Cat Form 768 + // Druid combat forms : Bear Form 5487 carries ModShapeshift(5), Cat Form 768 // carries form 1, Dire Bear Form 9634 carries form 8. assert_eq!(stance_p0(E_SET_STANCE, 5), 3); // Bear Form → stance 3 assert_eq!(stance_p0(E_SET_STANCE, 1), 4); // Cat Form → stance 4 @@ -3183,7 +3140,7 @@ mod tests { #[test] fn druid_form_switches_name_rescue_to_set_stance() { // The Druid combat-form switches carry the same inert ModShapeshift→A_FLAG marker as the Warrior - // stances and reclassify to E_SET_STANCE by name (work-item 156). + // stances and reclassify to E_SET_STANCE by name. assert_eq!( correct_script_effect_kind("Bear Form", A_FLAG), E_SET_STANCE @@ -3211,7 +3168,7 @@ mod tests { assert_eq!(translate_stance_mask(0x50000), 0x05); // Berserker-only would be vanilla bit18 → our bit2 (0x04). assert_eq!(translate_stance_mask(0x40000), 0x04); - // Druid combat forms survive translation (work-item 156 — previously dropped): + // Druid combat forms survive translation (previously dropped): // Maul/Growl-shaped Bear|DireBear masks: vanilla bits4,7 (0x90) → our bits3,5 (0x28). assert_eq!(translate_stance_mask(0x90), 0x28); // Bear-only (vanilla bit4) → our bit3 (0x08); Cat-only (vanilla bit0) → our bit4 (0x10). @@ -3280,7 +3237,7 @@ mod tests { spell_flag_attributes("Garrote"), SPELL_ATTR_REQ_BEHIND | SPELL_ATTR_REQ_STEALTH ); - // Pick Pocket: STEALTH_SAFE only (fixes the Phase-6 stealth-break) — no REQ flags. + // Pick Pocket: STEALTH_SAFE only (fixes the stealth-break), no REQ flags. assert_eq!( spell_flag_attributes("Pick Pocket"), SPELL_ATTR_STEALTH_SAFE @@ -3492,7 +3449,7 @@ mod tests { #[test] fn power_word_shield_p1_links_weakened_soul() { // The real live Power Word: Shield (17) A_ABSORB effect gets its p1 overridden to link Weakened - // Soul (6788) — the generic linked-debuff mechanic (work-item 013). + // Soul (6788), the generic linked-debuff mechanic. assert_eq!( power_word_shield_p1_override(17, "Power Word: Shield", A_ABSORB, 0), 6788 @@ -3528,7 +3485,7 @@ mod tests { assert_eq!(instant_effect_to_kind(91), E_TAUNT); // ThreatAll assert_eq!(instant_effect_to_kind(24), E_CREATE_ITEM); // CreateItem (conjure / quest item) assert_eq!(instant_effect_to_kind(62), E_POWER_BURN); // PowerBurn (Mana Burn) - assert_eq!(instant_effect_to_kind(33), E_OPEN_LOCK); // OpenLock (Pick Lock 1804, work-item 119) + assert_eq!(instant_effect_to_kind(33), E_OPEN_LOCK); assert_eq!(instant_effect_to_kind(59), E_OPEN_LOCK); // OpenLockItem (the item-lock variant) // LearnSpell / Summon are still out of taxonomy → graceful no-op. assert_eq!(instant_effect_to_kind(36), E_SCRIPTED); @@ -3536,7 +3493,7 @@ mod tests { #[test] fn instant_effect_raw_id_arms_land_work_item_101() { - // Exact-arm pins for the four raw ids work-item 101 lands (verified against + // Exact-arm pins for the four raw ids lands (verified against // `wow_world_base-0.3.0`'s vanilla/tbc/wrath `SpellEffect::from_int`, all three eras agree): // AttackMe=114, AddComboPoints=80, Resurrect=18, ResurrectNew=113. assert_eq!(instant_effect_to_kind(114), E_TAUNT); // AttackMe @@ -3573,7 +3530,7 @@ mod tests { use AuraMod::*; assert_eq!(aura_mod_to_kind(PeriodicDamage), A_PERIODIC_DAMAGE); assert_eq!(aura_mod_to_kind(PeriodicHeal), A_PERIODIC_HEAL); - // Demon Skin/Armor's health-per-5 (work-item 024): ModRegen is a combat-independent periodic + // Demon Skin/Armor's health-per-5 : ModRegen is a combat-independent periodic // heal, reclassified onto the SAME A_PERIODIC_HEAL kind as PeriodicHeal (Renew/bandages/food) — // NOT left as the inert A_FLAG marker. assert_eq!(aura_mod_to_kind(ModRegen), A_PERIODIC_HEAL); @@ -3590,7 +3547,7 @@ mod tests { // incoming-damage % modifier (Shield Wall / vulnerability) — wired; the FLAT variant stays no-op. assert_eq!(aura_mod_to_kind(ModDamagePercentTaken), A_MOD_DAMAGE_TAKEN); assert_eq!(aura_mod_to_kind(ModDamageTaken), E_SCRIPTED); // flat damage-taken not handled yet - // Spell modifiers (work-item 264): AddFlatModifier/AddPctModifier were the "unmapped → no-op" + // Spell modifiers : AddFlatModifier/AddPctModifier were the "unmapped → no-op" // example here until the passive-modifier engine landed (2000292) and gave them real kinds — // this assertion is the regression guard for that reclassification, not a stale count. assert_eq!(aura_mod_to_kind(AddFlatModifier), A_SPELLMOD_FLAT); @@ -3862,12 +3819,7 @@ mod tests { full[3], "DELETE FROM game_creature_ai_spell_metadata WHERE spell_id < 50000" ); - // Per danger-zones.md §2, a single-column range filter via `spacetime sql` can silently return - // 0 rows in some conditions — this guard's live behavior (fixtures 50000/50072/50110/50137 - // survive; all non-zero DBC rows below the floor ARE replaced) still NEEDS verification on a - // real node per the work-item's runbook; this test only pins the SQL string. - // Additive allowlist: one surgical delete per table and id, sorted and deduped. let surgical = spell_delete_statements(&[7386, 78, 78]); assert_eq!( surgical.len(), diff --git a/importer/src/talent.rs b/importer/src/talent.rs index 3ba74cf58..7b86d3bf6 100644 --- a/importer/src/talent.rs +++ b/importer/src/talent.rs @@ -1,17 +1,4 @@ -//! Stream 1 (client DBC) — `TalentTab.dbc` + `Talent.dbc` → `game_talent_tab` + `game_talent` importer. -//! Reads the operator's OWN `TalentTab.dbc`/`Talent.dbc` via the same `open_chain`/`read_table` path as -//! `dbc.rs`, and emits derived `game_*` rows as chunked clear+reload SQL (mirrors `spell.rs`'s shape). -//! This is the DATA half of work-item 031 (the real talent PANE is the wire/system half); a talent's -//! rank-1 spell (`spell_id`) may reference a `Spell.dbc` row that hasn't been imported yet — a missing -//! header is a graceful no-op at learn time, never a hard error (the rotation-latency doctrine — see -//! `module/src/talent.rs::do_learn_talent`). -//! -//! LICENSING FIREWALL: like the rest of `dbc.rs`, the client bytes stay in memory — NO `.dbc` (or any -//! Blizzard file) is ever written; only derived `game_talent_tab`/`game_talent` rows are emitted. -//! -//! Talent.dbc carries NO name field for a talent (the client shows the rank-1 spell's own name) — we -//! stamp a placeholder `"Talent {id}"` so the NOT NULL `game_talent.name` column stays populated; the -//! real display name is a client-side (Spell.dbc) concern the wire side already owns. +//! Client talent data extraction and import planning. use std::collections::{HashMap, HashSet}; use std::path::Path; diff --git a/importer/src/terrain.rs b/importer/src/terrain.rs index 1065d3435..bf285aa19 100644 --- a/importer/src/terrain.rs +++ b/importer/src/terrain.rs @@ -1,4 +1,4 @@ -//! `--terrain ` — ADT heightmap slice → `game_terrain_chunk` (work-item 172). +//! `--terrain `, ADT heightmap slice → `game_terrain_chunk`. //! //! Reads the operator's OWN client terrain MPQs (same licensing firewall as `--dbc`: extracted //! data never enters the repo), parses the ADT tiles covering the content slice with `wow-adt`, @@ -7,7 +7,7 @@ //! Heights are rebased to ABSOLUTE world Z here (MCVT is relative to the chunk header Z), so //! the module's `ground_z` is pure arithmetic. Dry-run prints the plan + a self-check; `--apply` //! calls the reducers. Honors `--map/--center/--radius`, or `--box X0,X1,Y0,Y1` in place of -//! `--radius` for an exact rectangle (work-item 206 — a wide zone like Westfall+Elwynn together +//! `--radius` for an exact rectangle, a wide zone like Westfall+Elwynn together //! needs a rectangle, not a center-radius circle's bounding square); `--center` still drives the //! interpolate self-check below, so put it INSIDE the box. @@ -19,7 +19,7 @@ use std::path::Path; use wow_mpq::PatchChain; /// Vanilla map id → client map directory name. Both CONTINENTS are supported: map 0 is the Elwynn -/// corridor, and map 1 is the Phase B Kalimdor world shard (issue #24) — `--terrain`/`--nav --map 1` +/// corridor, and map 1 is the Kalimdor continent, `--terrain`/`--nav --map 1` /// read `World\Maps\Kalimdor\*.adt` through this same code path, self-checks included. Instance /// terrain remains refused because this height row represents only one floor per cell. // Deliberate simplification: two-arm match, not Map.dbc — add arms (or read the DBC) when a third @@ -71,7 +71,6 @@ fn open_terrain_chain(data_dir: &Path) -> Result { Ok(chain) } -/// One output row, already in the reducer wire format's field order. pub(crate) struct CellRow { pub(crate) map_id: u32, pub(crate) cell_x: u16, @@ -108,7 +107,7 @@ fn cell_index(coord: f32) -> i32 { } /// Cell-index range `(x_min, x_max, y_min, y_max)` covering the content slice: a `--box` -/// rectangle when given (work-item 206 — the Westfall widening needed an exact rectangle, not a +/// rectangle when given, the Westfall widening needed an exact rectangle, not a /// center±radius bounding square that either misses corners or overshoots into neighbours), else /// the original center±radius square. Cell indices count DOWN as world coordinates grow (see /// `cell_index`), so a box's HIGH world coordinate (x1/y1) maps to the LOW cell index and vice @@ -227,14 +226,6 @@ fn collect_slice( let mut tiles_read = 0u32; for tx in tx_min..=tx_max { for ty in ty_min..=ty_max { - // ADT filename order is folkloric ({name}_{a}_{b}.adt — which index is which axis?), - // and for interior tiles BOTH candidate names exist (they're just different map - // locations), so file existence proves nothing (PR-9 review). Arbitrate by CONTENT: - // parse each candidate and accept the one whose own MCNK header positions land in - // this (tx, ty) tile's cell range. Wrong-order picks are a hard error, not a - // silently-holed slice. A tile with no ADT at all is open sea or an unbuilt map - // edge: the client ships no file there, so a slice rectangle that reaches the coast - // skips it with a warning instead of refusing the whole slice. let candidates = [ format!("World\\Maps\\{map_name}\\{map_name}_{ty}_{tx}.adt"), format!("World\\Maps\\{map_name}\\{map_name}_{tx}_{ty}.adt"), @@ -347,14 +338,6 @@ pub(crate) fn collect_cells( if mcvt.heights.len() != 145 { continue; } - // MCNK header position: [0]/[1] are the world x/y of the chunk corner, [2] the base - // height MCVT values are relative to. wow-adt 0.6.4's header.rs:238 doc claims the raw - // field is [Z, X, Y] (and offers world_position() to reorder) — that doc is WRONG for - // vanilla ADTs and contradicts line 106 of the same file. Arbitrated empirically at - // commit 14e5702 (PR-9 review): dry-run self-checks pass reading [X, Y, Z] at both the - // near-flat Northshire center (83.529 vs 83.5312) and a sloped point (80.400 exact); - // were [0] really Z (~80), cell_index(80)=509 would fall outside the slice band - // (774..784) and ZERO rows would collect. Worth reporting upstream. let (px, py, pz) = ( mcnk.header.position[0], mcnk.header.position[1], @@ -386,9 +369,6 @@ pub(crate) fn collect_cells( Ok(()) } -/// Interp at (x, y) from collected rows via the SAME `lyracore_shared::terrain::interpolate` the -/// module's ground_z calls — so the run()-time self-check verifies the import, not a second -/// copy of the math (PR-9 review). fn interp_check(rows: &[CellRow], x: f32, y: f32) -> Result { let (cx, cy) = (cell_index(x) as u16, cell_index(y) as u16); let row = rows @@ -465,7 +445,7 @@ mod tests { #[test] fn slice_cell_range_from_box_uses_corners_not_center_radius() { - // work-item 206: the widened Westfall box. High world X/Y (x1/y1) map to the LOW cell index + // the widened Westfall box. High world X/Y (x1/y1) map to the LOW cell index // (cell index counts DOWN as world coords grow) — the min/max pairing is corner-derived, not // center±radius, and radius is ignored entirely once a box is given. let (x0, x1, y0, y1) = (-11400.0f64, -8000.0, -1600.0, 2000.0); diff --git a/importer/src/vmap.rs b/importer/src/vmap.rs index 3984ef6bf..7032f6cab 100644 --- a/importer/src/vmap.rs +++ b/importer/src/vmap.rs @@ -1,30 +1,7 @@ -//! `--vmap ` — work-item #520 (part of the #169 full-vmap epic; design record: -//! `docs/decisions.md` §10). Exact per-cell collision triangles, replacing the nav rasterizer's -//! obstruction-height half. -//! -//! Reuses `nav.rs`'s tile scan (heights + deduped WMO/M2 placements), mesh loading, and MODF-bounds -//! rotation calibration verbatim — only the consumption differs: instead of rasterizing into a -//! walkability/obstruction grid, every collision triangle is transformed to world space with FULL -//! rotation (yaw + pitch + roll — the rasterizer's yaw-only limitation doesn't apply to an exact -//! per-triangle store), binned by the SAME 33.3 yd terrain cell key the nav grid uses, and packed -//! into a per-cell blob via `lyracore_shared::vmap` (source class + WMO group id + MOGP flags). -//! -//! #520 stopped at extract + pack + report. #521 adds `--apply`: batches each cell's packed blob -//! into `import_vmap_chunks`/`import_vmap_chunks_append` calls (same convention as `--nav`), and -//! the module-side table + LoS/collision ray queries those reducers feed live in -//! `module/src/vmap.rs` + `lyracore_shared::vmap::cast_ray`. Same licensing firewall as -//! `--nav`/`--terrain`: in-memory only, nothing written to disk. -//! -//! `--vmap-prepare-coverage ` drives path-grid coverage derivation for an -//! already-staged generation (`run_coverage` below): no client Data/ dir, since the geometry is -//! already staged and the module derives coverage from it directly. Enumerates the generation's -//! own cells, batches `prepare_vmap_nav_coverage` calls, and finalizes — resumable by skipping -//! cells a prior run already covered. -//! -//! An Instance Vmap Slice reads only the ADT tiles crossed by its route collar and uses the same -//! triangle transform, packer, and generation lifecycle as continent geometry. Instance terrain -//! and Navigation Coverage remain unavailable because their current rows represent one floor per -//! cell. +//! Client geometry extraction for `--vmap` and `--vmap-prepare-coverage`. +//! Tile scans reuse nav mesh loading and placement calibration. Collision triangles use full +//! rotation and the shared per-cell codec. `--apply` sends bounded batches to the import reducers. +//! Client bytes stay in memory. See `docs/decisions.md` section 10 for the geometry policy. use anyhow::{bail, Context, Result}; use lyracore_shared::terrain::cell_key; @@ -558,18 +535,18 @@ pub(crate) fn run(args: &crate::Args) -> Result<()> { route.geometry_ready(), format_point(route.samples.first().and_then(|sample| sample.support_floor)), route - .supported_exit_sample - .map(|index| index.to_string()) - .unwrap_or_else(|| "none".to_owned()), +.supported_exit_sample +.map(|index| index.to_string()) +.unwrap_or_else(|| "none".to_owned()), format_position( route - .supported_exit_sample - .and_then(|index| route.samples[index].supported_position()) +.supported_exit_sample +.and_then(|index| route.samples[index].supported_position()) ), format_point( route - .supported_exit_sample - .and_then(|index| route.samples[index].support_floor) +.supported_exit_sample +.and_then(|index| route.samples[index].support_floor) ), format_point(route.samples.last().and_then(|sample| sample.support_floor)), format_hit(route.direct_step_hit), @@ -603,9 +580,9 @@ pub(crate) fn run(args: &crate::Args) -> Result<()> { format_point(sample.model_floor), format_point(sample.support_floor), sample - .floor_delta - .map(|value| format!("{value:.4}")) - .unwrap_or_else(|| "none".to_owned()), +.floor_delta +.map(|value| format!("{value:.4}")) +.unwrap_or_else(|| "none".to_owned()), format_hit(sample.headroom_hit), format_hit(sample.step_hit), ); @@ -950,11 +927,11 @@ fn build_instance_plan( placement.name, inspection.unique_id, inspection - .relevant_refs - .iter() - .map(u32::to_string) - .collect::>() - .join(", ") +.relevant_refs +.iter() +.map(u32::to_string) +.collect::>() +.join(", ") )); } inspections.push((placement, inspection)); @@ -1477,10 +1454,7 @@ fn apply_plan(args: &crate::Args, plan: &VmapPlan) -> Result<()> { /// `nav.rs`'s `BATCH_BYTES`). const BATCH_BYTES: usize = 28_000; -/// Hard cap on ONE shard's packed byte size, well under the ~128 KB (`MAX_ARG_STRLEN`) a single -/// Linux argv string can hold even after this blob doubles in size as hex text — the failure this -/// guards was measured live (`Argument list too long`, os error 7) importing a dense WMO cell near -/// the Northshire abbey before this cap existed. +/// Keep each hex-encoded row below Linux's ~128 KB argv-string limit. const MAX_ROW_TRI_BYTES: usize = 20_000; /// Bounds the 45-byte spool records retained by one Instance Vmap Slice to 22.5 MB. @@ -1826,12 +1800,7 @@ mod tests { assert_eq!(back.len(), 5); } - /// A dense cell (more triangles than one `MAX_ROW_TRI_BYTES`-capped blob can hold) splits into - /// several shards, each individually decodable and each within the byte cap; the union of - /// every shard's decoded triangles recovers the exact original set (unordered) — this is the - /// live-measured fix for the `Argument list too long` failure importing a dense WMO cell near - /// the Northshire abbey (a single oversized row exceeded Linux's ~128 KB argv-string limit - /// before this cap existed). + /// Dense cells split into byte-capped shards whose decoded union preserves every triangle. #[test] fn a_dense_cell_splits_into_byte_capped_shards_that_reassemble_exactly() { let per_shard = (MAX_ROW_TRI_BYTES - HEADER_BYTES) / TRI_BYTES; diff --git a/module/build.rs b/module/build.rs index 5e9048659..a8a4babc1 100644 --- a/module/build.rs +++ b/module/build.rs @@ -1,86 +1,11 @@ -//! Build-time codegen for the module's self-registration registries. -//! -//! Scans every `.rs` file under `src/` AND under each drop-in package's `packages//src/` -//! at the repository root for marker invocations. Packages are installed separately. Generates -//! into `$OUT_DIR` the files the crate `include!`s: -//! -//! - `character_sweeps.rs` — from `character_owned!` markers: the two `&[fn(...)]` -//! const arrays `CHARACTER_OWNED_DELETE_SWEEPS` and `CHARACTER_OWNED_RESTAMP_SWEEPS`, plus -//! `CHARACTER_OWNED_TABLES: &[&str]` — the same enumeration as NAMES, which is what the escrowed -//! transfer manifest (`src/transfer.rs`, issue #16) exports. The names are derived from the -//! `delete` sweep fn names by stripping the mandatory `sweep_delete_` prefix (every marker in the -//! tree follows it), so the manifest can never drift from the sweep registry — there is exactly -//! ONE list, and a table that gets a sweep gets a manifest entry in the same edit. A `delete` -//! marker whose fn is not named `sweep_delete_` panics below: the transfer -//! manifest would silently name a non-existent table. The `transfer` and `not_transported` marker -//! kinds add `CHARACTER_OWNED_TRANSFERS: &[(&str, fn(..))]` (issue #19) — the cross-DATABASE row -//! transport, keyed by the same table-accessor name so a mover can never be paired with the wrong -//! manifest entry (`sweep_transfer_`, same prefix-strip rule) — plus two -//! plain-string views of it (#380): `CHARACTER_OWNED_TRANSFER_NAMES` (every transported table, so -//! a NATIVE test binary can read the registry without materializing fn pointers it cannot link) -//! and `CHARACTER_OWNED_NOT_TRANSPORTED` (the subset registered through the `not_transported` -//! kind, cross-checked against `transfer::NOT_TRANSPORTED`'s written reasons). A declining arm is -//! still a transport arm — it lands in `CHARACTER_OWNED_TRANSFERS` too — so "this table has an -//! arm" and "this table's rows actually cross" stay two separate, separately-ratcheted facts. -//! - `package_mods.rs` — one `#[path = ...] pub mod pkg_;` per discovered package, so a -//! folder dropped into `packages/` compiles into the module wasm with ZERO core-file edits. -//! A package = `packages//src/mod.rs` (+ sibling submodule files). -//! - `package_registries.rs` — from `game_tick_pass!`, `game_hook!`, -//! `game_client_command!`, and `encounter_package!` markers: -//! `GAME_TICK_PASSES` (periodic passes run by the core scheduler tick) and one -//! `GAME_HOOKS_` array per known notify-hook event, dispatched at the core chokepoints -//! (see `src/hooks.rs`), the optional Package client-command handler, and the map-scoped -//! encounter authority registry. Each Package registration is wrapped so it stops once Package -//! Teardown has run (`src/package_teardown.rs`). `GAME_PACKAGES` lists every compiled Package -//! with its `#[table]` accessors and its `game_package_characters!` read, for that teardown. -//! - `hook_dispatch.rs` — from `HOOK_EVENTS` below: the `payload_for` alias mod -//! and one `fire_*` fn per event, included INSIDE `src/hooks.rs` so the paths every chokepoint -//! already uses (`hooks::fire_*`, `hooks::payload_for::*`) are unchanged. This is what keeps the -//! event catalog to TWO homes: the payload struct in hooks.rs and the `HOOK_EVENTS` row here. -//! -//! This is a text scan, not a real Rust parser — it never inspects a marker's body, only the shape -//! of the invocation head. The scan runs on a COMMENT- AND STRING-STRIPPED copy of each -//! file (newlines preserved, so panic line numbers stay true): a commented-out marker, or marker -//! syntax quoted in a doc example or string literal, neither registers nor breaks the build — the -//! macro docs in `src/lib.rs` show real invocation syntax precisely because of this. On the CODE -//! that remains, the scan is deliberately strict: every occurrence of a marker's literal substring -//! must match the exact expected shape, or the build panics (loudly, at `cargo build`/`cargo -//! check` time) instead of silently registering nothing. A typo'd or reshaped marker must never -//! just vanish from the registry. -//! -//! The fully-qualified path is derived from the file's location, using plain Rust module -//! conventions collapsed to the top segment: `src/foo.rs` -> `crate::foo`, `src/foo/bar.rs` -> -//! `crate::foo` (every directory module in this crate is a thin facade whose `mod.rs` does -//! `pub use bar::*;`). Package files map to the package's generated root module: -//! `packages//src/**` -> `crate::pkg_`. The facade assumption is CHECKED: -//! a marker in a nested submodule file whose facade does not visibly re-export it panics at build -//! time naming the missing `pub use` — instead of failing later as an opaque rustc error inside -//! `$OUT_DIR`. -//! A source file compiled only with `debug_reducers` must put the canonical inner attribute -//! `#![cfg(feature = "debug_reducers")]` before every non-blank line. Registry discovery applies -//! that same feature boundary, so an ordinary build never receives paths to functions rustc omitted. -//! -//! The same pass also lints each package file against the Package API surface -//! (`PACKAGE_API_ROOTS`, documented at `docs/package-api.md`): a path that reaches the crate root -//! outside it fails the build naming the Package, the file, the line and the path, unless the line -//! carries `// package-api: exempt `. A gated root (`PACKAGE_API_GATED_ROOTS`) is on the -//! surface only in a file that starts with its gate, the debug cfg above or `#![cfg(test)]`, and no -//! exemption clears it. Core `src/` is never linted. -//! -//! EVALUATED AND REJECTED: replacing the marker scan with an explicit per-package `register()` -//! convention. The registries are const fn-pointer arrays (no allocator-dependent init order in -//! wasm), and `character_owned!` markers are deliberately scattered NEXT TO their tables across -//! core files — a tripwire test enforces that locality. A central register() would keep codegen -//! anyway (package discovery) while losing the write-the-marker-where-the-code-lives property the -//! substrate is built on. Hardening the scan (strip + facade check) buys the same safety without -//! the churn. +//! Module registry generation and Package discovery. See `docs/module-build.md`. use std::fs; use std::path::{Path, PathBuf}; /// One row of the notify-hook event catalog. /// -/// `actor`/`target` are the Runtime Script Event Binding half (#318): Rust expressions, evaluated +/// `actor`/`target` are the Runtime Script Event Binding half: Rust expressions, evaluated /// against `payload` inside the generated `fire_*`, naming the guid that CAUSED the event and the /// guid it acted ON. `"0"` means the event has no such participant, which reaches a Runtime Script /// as an absent `event.actor`/`event.target` rather than as an error. @@ -428,7 +353,7 @@ fn main() { out.push_str(&format!(" \"{table}\",\n")); } out.push_str("];\n"); - // The CROSS-DATABASE row transport (issue #19), keyed by table accessor so + // The CROSS-DATABASE row transport, keyed by table accessor so // `transfer::export_rows`/`import_rows` can pair a manifest entry with its mover. Derived from // the `sweep_transfer_` fn names by the same prefix-strip rule as the delete // sweeps — so a transport arm can never name a table that isn't in the manifest. @@ -464,7 +389,7 @@ fn main() { )); } out.push_str("];\n"); - // The SAME names again, as plain strings (#380). `CHARACTER_OWNED_TRANSFERS` above cannot be + // The SAME names again, as plain strings. `CHARACTER_OWNED_TRANSFERS` above cannot be // named from a NATIVE test binary — referencing it materializes every registered fn's POINTER, // which drags the SpacetimeDB host imports (`datastore_insert_bsatn`, …) in and they cannot // link outside wasm. The transfer ratchet used to work around that by string-parsing this very @@ -478,7 +403,7 @@ fn main() { // The DECLINING subset — the arms written with the `not_transported` marker kind. This is the // mechanical half of the decision; the reasoned half is `transfer::NOT_TRANSPORTED`, and // `the_not_transported_allowlist_matches_the_arms_that_decline` fails if they disagree in - // either direction (#380). Sorted by TABLE name so the assertion compares two stable lists. + // either direction. Sorted by TABLE name so the assertion compares two stable lists. registries.not_transported.sort(); let mut declines: Vec = registries .not_transported @@ -692,7 +617,7 @@ struct Registries { restamp: Vec, transfer: Vec, /// The subset of `transfer` registered through the `not_transported` marker kind — the arms - /// that deliberately carry nothing. Emitted as `CHARACTER_OWNED_NOT_TRANSPORTED` (#380). + /// that deliberately carry nothing. Emitted as `CHARACTER_OWNED_NOT_TRANSPORTED`. not_transported: Vec, tick_passes: Vec, hooks: Vec<(String, String)>, // (event, fully-qualified fn path) @@ -1248,7 +1173,7 @@ fn scan_file(file: &Path, scan_root: &Path, in_package: bool, prefix: &str, reg: // seeing an arm for the table — but it is ALSO recorded separately, because // "these rows deliberately do not cross" is a decision that must be // cross-checkable against `transfer::NOT_TRANSPORTED`'s written reasons - // instead of being read back out of the arm's source text (#380). + // instead of being read back out of the arm's source text. // A PACKAGE's decline is registered as a transport arm like any other, but it // is NOT cross-checked against `transfer::NOT_TRANSPORTED`. That list is the // core's written decision about core tables, and it cannot name a table that diff --git a/module/src/action_bar.rs b/module/src/action_bar.rs index e95549db9..99b407b5e 100644 --- a/module/src/action_bar.rs +++ b/module/src/action_bar.rs @@ -1,7 +1,7 @@ //! Creation-time action-bar rows — `game_player_action` is the per-character bar the gateway's login //! sequence reads FIRST (before falling back to synthesizing a bar from the spellbook, see //! `gateway/src/codec/entity.rs::login_sequence_messages`). Mirrors the `game_createinfo_spell` / -//! `game_player_spell` precedent (work-item 178) one level over: `game_createinfo_action` is the +//! `game_player_spell` precedent one level over: `game_createinfo_action` is the //! per-(race, class) dump-sourced default layout (`playercreateinfo_action`), copied into //! `game_player_action` at character creation exactly like `grant_createinfo_spells` copies the spell //! kit. UNLIKE the spell kit there is no hand-authored fallback seeded into `game_createinfo_action` — @@ -24,7 +24,7 @@ use crate::spell::spellbook::createinfo_row_matches; // shared (race,class) wild /// `action` | `action_type` mirror the client's action-button encoding (`action_type` 0 = spell, other /// values = item/macro/etc — see `gateway/src/codec/entity.rs`'s packing). Hand-authored data has no /// client DBC of its own (createinfo exists in no DBC); this table is entirely importer-populated -/// (`playercreateinfo_action`, work-item 212) — never seeded, unlike `game_createinfo_spell`. `row_id` +/// (`playercreateinfo_action`), never seeded, unlike `game_createinfo_spell`. `row_id` /// is an importer-assigned dense id (the `game_start_item`/`game_graveyard_zone` convention), not a /// logical key — the table is a flat bag of rows scanned in full, like `game_createinfo_spell`. [static] #[table(accessor = game_createinfo_action, public)] diff --git a/module/src/actor.rs b/module/src/actor.rs index 9020636f3..acf37788a 100644 --- a/module/src/actor.rs +++ b/module/src/actor.rs @@ -1,65 +1,4 @@ -//! The ACTOR VERB API: one documented surface over every explicit-guid action -//! core, so anything that acts ON BEHALF OF a unit — the `debug_*` harness reducers, Package -//! bots, and the future Tier-2 Lua host API — consumes the SAME verbs the -//! player reducers do, with identical gates. -//! -//! Existing verbs retain their `Result<(), String>` contract. Typed requests expose accepted -//! work and pending cast identity without changing the client operations. All Gates remain -//! in the operation that owns them. Callers authorize the Actor before entering these functions. -//! -//! | verb | core | gate semantics (unchanged, documented here for consumers) | -//! |------|------|------------------------------------------------------------| -//! | `attack` | `combat::apply_start_attack` | CC-blocked rejected; no self/corpse/cross-map; friendly (green) target rejected when faction data exists; re-arm retargets | -//! | `request_attack` | `combat::request_attack` | typed acceptance or Refusal; keeps a matching swing timer; an in-range Character turns toward the exact target when the facing Gate would block its swing unless an active movement leg owns facing; acceptance does not imply damage | -//! | `ranged_attack` | `combat::apply_start_ranged_attack` | `attack` gates + ranged weapon equipped (slot 17) | -//! | `stop_attack` | `combat::stop_attack_for` | unconditional disarm of the actor's outgoing melee row | -//! | `cast_at` | `spell::request_cast` | normal cast lifecycle; an existing timed cast waits; level comes from the live entity | -//! | `cast_readiness` | `spell::cast_readiness` | read-only spellbook, supported-lifecycle, and cast Gate check | -//! | `request_cast` | `spell::request_cast` | typed start, waiting, and Refusal; completion uses `on_cast_finished` | -//! | `accept_quest` | `quest::apply_accept_quest` | alive + giver in range offering the quest + level/race/class/prereq/duplicate gates | -//! | `stage_quest` | `quest::grant_quest_unchecked` | HARNESS/BOT staging: same row shape, all accept gates SKIPPED (giver-less) | -//! | `turn_in_quest` | `quest::apply_turn_in_quest` | alive + giver in range ending the quest + objectives complete; rewards atomic | -//! | `open_creature_loot` | `loot::open_creature_corpse` | legacy result adapter used by the Gateway | -//! | `request_open_creature_loot` | `loot::request_open_creature_corpse` | typed open acceptance or Refusal; applies the same corpse and Loot Tag Gates | -//! | `take_loot` | `items::apply_take_loot` | legacy result adapter used by the existing rest-and-loot goal | -//! | `request_take_loot` | `items::request_take_loot` | typed take completion or Refusal; inventory-full leaves the item and Loot Source unchanged | -//! | `loot_money` | `loot::apply_loot_money` | alive + dead creature corpse, same map, 10yd, money > 0 + Loot Tag eligibility | -//! | `buy_item` | `items::apply_buy_item` | vendor in range + stocked + money; stacks/slots validated | -//! | `sell_item` | `items::apply_item_sell` | vendor in range + sellable item in slot; feeds the buyback ring | -//! | `use_item` | `items::apply_item_use` | alive + usable item in slot (consumable/on-use gates) | -//! | `cast_item_target` | `creatures::apply_item_target_spell` | spell kind + owned carried item + effect-specific gates | -//! | `equip_item` | `items::apply_equip_item` | slot type/level gates | -//! | `trainer_buy` | `trainer::apply_trainer_buy` | trainer resolved by guid + offering gates (class/level/money) | -//! | `reconcile_profile_spell` | `trainer::reconcile_profile_spell` | free profile grant + trainer/class/level/rank gates; explicit no-import demo fallback | -//! | `reconcile_profile_skill` | `skill::reconcile_profile_skill` | free profile grant + race/class/level availability gates | -//! | `select_profile_talent` | `talent::select_profile_talent` | bounded preferred-tree selection through the owning talent Gates | -//! | `learn_profile_talent` | `talent::reconcile_profile_talent` | class/race/level/point/tier/prerequisite gates | -//! | `reconcile_profile_item` | `items::request_profile_item` | bounded top-up + capacity/uniqueness gates | -//! | `equip_profile_upgrade` | `items::apply_equip_profile_upgrade` | normal equip gates + preserves equal or stronger gear | -//! | `reconcile_starter_role_spell_levels` | `seed::reconcile_curated_starter_role_levels` | repairs only exact old curated level-zero headers; preserves imported or tuned rows | -//! | `use_gameobject` | `gameobject::apply_use_gameobject` | GO resolved by guid + range/use gates | -//! | `request_use_gameobject` | `gameobject::request_use_gameobject` | typed target, partition, range, and use Refusal | -//! | `area_trigger_route` | `quest::area_trigger_route` | exact imported source volume and target map; landing remains private | -//! | `enter_sessionless_areatrigger` | `quest::enter_sessionless_areatrigger` | imported source volume + expected certified party partition + session-less action Gates | -//! | `import_revision` | `game_import_meta` read | current importer source and file identities for one family | -//! | `repop` | `world::do_repop` | dead actor releases to the graveyard ghost | -//! | `respond_resurrect` | `spell::do_resurrect_response` | consume the actor's pending rez offer; accept revives IN PLACE at the offer's % | -//! | `spirit_res` | `world::do_spirit_healer_res` | ghost actor res at the spirit healer (sickness applies) | -//! | `self_resurrect` | `spell::do_self_resurrect` | dead actor with a Self-Resurrection Option revives in place; no sickness | -//! | `accept_group_invite` | `group::accept_invite_for` | pending invite exists + its Group still exists (or a solo inviter is still ungrouped) + group not full; roster events fire | -//! | `set_sessionless_action_consent` | `sessionless::set_sessionless_action_consent` | update Package consent and clear unclaimed Group Intents atomically | -//! | `companion_target_facts` | `group::companion_target_facts` | exact hostile creature + partition/death/control gates; never selects a substitute | -//! | `system_message` | `chat::emit_system_message` | recipient exists and is online on this Shard; text is trimmed, bounded, and non-empty | -//! -//! Adding a verb = add the row above + the `use ... as` below; if the underlying core is still -//! inlined in a `ctx.sender()`-gated player reducer, factor it out FIRST (code motion, reducer -//! delegates). A verb nobody consumes is DEAD — delete it, don't suppress the lint (buyback/repair -//! were culled exactly that way). -//! -//! Two of this file's consumers are OPTIONAL trees rather than absent ones, and each has a macro -//! that says so: `debug_only!` (the `debug_reducers`-gated harness) and `package_only!` (the -//! `packages/` drop-ins, discovered by `module/build.rs`). Both silence unused-import ONLY in the -//! build where the consumer isn't compiled, and neither is a licence to keep a verb no tree calls. +//! Actor verbs. See `docs/actor-verbs.md` for the contract and rationale. use crate::import_meta::game_import_meta; use spacetimedb::ReducerContext; @@ -82,11 +21,6 @@ pub(crate) fn import_revision(ctx: &ReducerContext, family: &str) -> Option { $( diff --git a/module/src/auction.rs b/module/src/auction.rs index bf8b3b650..3831fedc3 100644 --- a/module/src/auction.rs +++ b/module/src/auction.rs @@ -110,12 +110,12 @@ pub struct AuctionHold { // Hold exists. crate::character_owned!(delete, fn sweep_delete_game_auction_hold(ctx, character_guid) { let operations: Vec = ctx - .db - .game_auction_hold() - .by_seller() - .filter(&character_guid) - .map(|hold| hold.operation_id) - .collect(); +.db +.game_auction_hold() +.by_seller() +.filter(&character_guid) +.map(|hold| hold.operation_id) +.collect(); for operation_id in operations { ctx.db.game_auction_hold().operation_id().delete(operation_id); } @@ -195,12 +195,12 @@ pub struct AuctionBidHold { // delete sweep only removes finished rows. crate::character_owned!(delete, fn sweep_delete_game_auction_bid_hold(ctx, character_guid) { let operations: Vec = ctx - .db - .game_auction_bid_hold() - .by_bidder() - .filter(&character_guid) - .map(|hold| hold.operation_id) - .collect(); +.db +.game_auction_bid_hold() +.by_bidder() +.filter(&character_guid) +.map(|hold| hold.operation_id) +.collect(); for operation_id in operations { ctx.db.game_auction_bid_hold().operation_id().delete(operation_id); } @@ -2621,7 +2621,7 @@ pub fn gw_auction_list_local( .map_err(|refusal| refused(refusal, "listing rejected")) } -/// Sharded listing phase 1: atomically move the source value into a caller-identified Hold. +/// Sharded listing: atomically move the source value into a caller-identified Hold. #[reducer] #[allow(clippy::too_many_arguments)] pub fn gw_auction_hold_listing( @@ -2687,7 +2687,7 @@ pub fn gw_auction_hold_listing( .map_err(|refusal| refused(refusal, "listing Hold rejected")) } -/// Sharded listing phase 2: create the realm Auction and idempotency receipt from a held payload. +/// Sharded listing: create the realm Auction and idempotency receipt from a held payload. #[reducer] #[allow(clippy::too_many_arguments)] pub fn realm_auction_commit_listing( @@ -2771,7 +2771,7 @@ fn require_listing_actor( Ok(()) } -/// Sharded listing phase 3: copy the matching realm receipt onto the source shard. +/// Sharded listing: copy the matching realm receipt onto the source shard. #[reducer] pub fn realm_auction_confirm_listing( ctx: &ReducerContext, @@ -2799,7 +2799,7 @@ pub fn realm_auction_confirm_listing( .map_err(|refusal| refused(refusal, "listing receipt conflict")) } -/// Sharded listing phase 4: delete the Hold only after the source has matching receipt evidence. +/// Sharded listing: delete the Hold only after the source has matching receipt evidence. #[reducer] pub fn realm_auction_settle_listing( ctx: &ReducerContext, @@ -2812,7 +2812,7 @@ pub fn realm_auction_settle_listing( .map_err(|refusal| refused(refusal, "listing Hold is not confirmed")) } -/// Sharded listing abort phase 3: Realm-core commits the seller's exact return Mail and durable +/// Sharded listing abort: Realm-core commits the seller's exact return Mail and durable /// refund receipt together. Replays with the same payload do not create a second Mail. #[reducer] #[allow(clippy::too_many_arguments)] // The persisted refund receipt's value columns. @@ -2867,7 +2867,7 @@ pub fn realm_auction_refund_listing( .map_err(|refusal| refused(refusal, "listing refund conflict")) } -/// Sharded listing abort: after realm-core refuses phase 2, mail the held item and deposit back +/// Sharded listing abort: after realm-core refuses, mail the held item and deposit back /// to the seller and delete the Hold. The gateway calls this only after Realm-core has committed /// the matching refund receipt and Mail. Refused once the Hold has a receipt. #[reducer] @@ -2985,7 +2985,7 @@ pub fn gw_auction_cancel_local( drive_local_hold(ctx, request, auctioneer_guid) } -/// Sharded bid phase 1: move the complete offer into a source-shard Hold before realm-core decides. +/// Sharded bid: move the complete offer into a source-shard Hold before realm-core decides. #[reducer] pub fn gw_auction_hold_bid( ctx: &ReducerContext, @@ -3009,7 +3009,7 @@ pub fn gw_auction_hold_bid( gate_and_fence(ctx, request, auctioneer_guid) } -/// Sharded Cancellation phase 1: move the Auction Cut into a source-shard Hold before realm-core +/// Sharded Cancellation: move the Auction Cut into a source-shard Hold before realm-core /// decides. A seller who cannot pay it is refused with nothing held. #[reducer] pub fn gw_auction_hold_cancel( @@ -3034,7 +3034,7 @@ pub fn gw_auction_hold_cancel( gate_and_fence(ctx, request, auctioneer_guid) } -/// Sharded bid phase 2: serialize against the realm Auction and persist one terminal decision. +/// Sharded bid: serialize against the realm Auction and persist one terminal decision. #[reducer] pub fn realm_auction_decide_bid( ctx: &ReducerContext, @@ -3061,7 +3061,7 @@ pub fn realm_auction_decide_bid( .map_err(|refusal| refused(refusal, "bid decision conflict")) } -/// Sharded Cancellation phase 2: serialize against the realm Auction and persist one terminal +/// Sharded Cancellation: serialize against the realm Auction and persist one terminal /// decision. A Cancelled decision removes the listing and writes its mail and notice in the same /// transaction; a replay changes nothing. #[reducer] @@ -3090,7 +3090,7 @@ pub fn realm_auction_decide_cancel( .map_err(|refusal| refused(refusal, "Cancellation decision conflict")) } -/// Sharded phase 3 for a bid or a Cancellation: consume the accepted price or the cut, or restore +/// Sharded for a bid or a Cancellation: consume the accepted price or the cut, or restore /// refused value, exactly once. The operation comes from the stored Hold. #[reducer] #[allow(clippy::too_many_arguments)] @@ -3138,7 +3138,7 @@ pub fn gw_auction_finish_bid( .map_err(|refusal| refused(refusal, "Hold outcome conflict")) } -/// Sharded phase 4 for a bid or a Cancellation: place an unrepresentable purse refund in +/// Sharded for a bid or a Cancellation: place an unrepresentable purse refund in /// realm-core mail exactly once. The operation comes from the stored decision. #[reducer] pub fn realm_auction_refund_bid( @@ -3167,7 +3167,7 @@ pub fn realm_auction_refund_bid( .map_err(|refusal| refused(refusal, "Hold refund conflict")) } -/// Sharded phase 5 for a bid or a Cancellation: record on the source that realm-core durably +/// Sharded for a bid or a Cancellation: record on the source that realm-core durably /// accepted the refund mail. The operation comes from the stored Hold. #[reducer] pub fn gw_auction_confirm_bid_refund( @@ -5767,7 +5767,7 @@ mod tests { assert_eq!(source.money, Some(40)); assert!(source.item.is_none()); - // Realm-core refused phase 2 (for example, its item catalogue lacks the template), so + // Realm-core refused (for example, its item catalogue lacks the template), so // the market never took the listing and the source Hold is the only copy of the value. // The refund Mail and its receipt are written on Realm-core before its source Hold moves. let refund = listing_refund(&source.hold(request.operation_id).unwrap().listing); diff --git a/module/src/auth.rs b/module/src/auth.rs index 4e1bd1a70..18d9c7d04 100644 --- a/module/src/auth.rs +++ b/module/src/auth.rs @@ -491,11 +491,6 @@ fn legacy_guid_seed( .max(owned_guids.max().unwrap_or(0)) } -/// The whole decision behind `next_character_guid`, pure: advance the seed by exactly one — no -/// ratchet needed here, since the seed IS the current mark (or the legacy scan's max), so "advance" -/// can never be a floor call in disguise. Mutation target: this is where a `next_character_guid` -/// that stops allocating (e.g. swapping `seed + 1` for `seed`) would live if it weren't pulled out -/// here — pulling it out is what lets a ctx-free test catch that mutation at all (playbook §7). fn allocate_next_guid(seed: u64) -> u64 { seed + 1 } @@ -537,10 +532,7 @@ fn write_high_water(ctx: &ReducerContext, existing: Option, mark: } } -/// Allocate the next character guid, durably. Replaces `create_character`'s old inline scan. Thin -/// on purpose: the only decision here — [`allocate_next_guid`] — is pure and unit-tested; this -/// wrapper is just the read/compute/write around it. Pinned by [`guid_allocator_tests::next_character_guid_routes_through_allocate_next_guid`] -/// (ctx glue can't be unit-tested directly — playbook §7). +/// Allocate and persist the next Character guid from the durable high-water mark. pub(crate) fn next_character_guid(ctx: &ReducerContext) -> u64 { let existing = read_high_water(ctx); let seed = existing @@ -711,15 +703,6 @@ pub fn install_guid_range(ctx: &ReducerContext, base: u64) -> Result<(), String> mod guid_allocator_tests { use super::{allocate_next_guid, legacy_guid_seed, ratchet_high_water}; - /// Two shards that both mint characters must never mint the same guid, and the - /// mechanism is a per-shard floor. The property that makes a floor SAFE to apply to a live - /// database — and safe to re-apply — is that it can only ratchet UP: a floor below the current - /// mark must not rewind the allocator onto guids that are already issued. `install_guid_range` - /// (below) is the only caller left that applies one (deleted the standalone - /// `set_guid_floor` reducer — dead once every shard's floor got a licensed, non-optional - /// path instead), and it reaches the mark through this same function, so this pin still covers - /// it. Mutation target: swapping `ratchet_high_water(seed, floor)` for a plain assignment inside - /// `bump_guid_high_water` makes the second assertion re-issue guid 40. #[test] fn a_guid_floor_only_ever_ratchets_upwards() { // A fresh shard given a floor: the mark jumps to it, so the next guid is above it. @@ -808,7 +791,6 @@ mod guid_allocator_tests { assert!(!in_guid_range(range, 3_000_000_000)); } - /// Mutation target: flip `.max` to `.min` (or drop the `floor` arm entirely) and this goes red. #[test] fn ratchet_high_water_never_moves_backwards() { assert_eq!( @@ -824,8 +806,6 @@ mod guid_allocator_tests { assert_eq!(ratchet_high_water(0, 0), 0); } - /// Mutation target: swap the `.max` between the two iterators, or drop either arm, and this - /// goes red — it's the exact scan `create_character` used to run inline. #[test] fn legacy_guid_seed_is_the_max_of_both_sources() { assert_eq!( @@ -839,10 +819,6 @@ mod guid_allocator_tests { assert_eq!(legacy_guid_seed(std::iter::empty(), std::iter::empty()), 0); } - /// Mutation target: reproduces the reviewer's exact find — `next_character_guid` used to inline - /// `ratchet_high_water(current, current + 1)` with no ctx-free seam to catch a mutation that - /// swapped it for plain `current` (never advances). That decision now lives in - /// `allocate_next_guid`, so this test alone catches it. #[test] fn allocate_next_guid_always_advances_past_the_seed() { assert_eq!(allocate_next_guid(50), 51); @@ -854,14 +830,6 @@ mod guid_allocator_tests { ); } - /// Defect 1, COMPOSED end-to-end through the pure functions (no ctx, so this can run - /// as a plain unit test): a database with characters up to 49 has JUST cascade-deleted guid 50 - /// (a departed transfer) — `legacy_guid_seed` therefore only sees the survivors, exactly the - /// scan `create_character` used to run inline. Flooring the mark at 50 (what - /// `cascade_delete_character`'s new `bump_guid_high_water` call does) must make the NEXT - /// allocation land on 51, never re-issue the departed 50. This is the outcome the individual - /// `ratchet_high_water`/`allocate_next_guid` tests above don't quite prove on their own — this - /// one composes them the way production actually does, guid-for-guid. #[test] fn defect_1_transfer_then_create_no_longer_reissues_the_departed_guid() { let chars_after_transfer = [10u64, 30, 49]; // guid 50 already gone when this runs @@ -1059,7 +1027,7 @@ pub(crate) fn insert_new_character( death_expire_micros: 0, // never died // fresh character, never respec'd pending_instance_id: 0, // open world gm_level: 0, // no GM access until granted via set_gm_level - pending_ghost: false, // fresh characters are alive (work-item 226) + pending_ghost: false, resting: false, // 196: not in an inn until they walk into one rested_since_micros: 0, // 196: live-accrual clock idle pending_godmode: false, // 289: GM playtest carry — off until `.god` + a map change @@ -1082,16 +1050,11 @@ pub(crate) fn insert_new_character( // the one castability source (`knows_spell`) and the gateway's SMSG_INITIAL_SPELLS both read them. let owner = account.identity.unwrap_or(Identity::ZERO); crate::spell::spellbook::grant_createinfo_spells(ctx, next_guid, owner, race, class); - // Action-bar rows (work-item 212): copies `game_createinfo_action` the same way the spell grant + // Action-bar rows : copies `game_createinfo_action` the same way the spell grant // above copies `game_createinfo_spell` — a no-op pre-import (table empty), so the gateway's login // synth covers it until the dump is imported. crate::action_bar::grant_createinfo_actions(ctx, next_guid, owner, race, class); - // And the starter LOADOUT (outfit + hearthstone) — at creation, not first login, so the - // char-select screen renders the gear (SMSG_CHAR_ENUM reads item rows; a never-logged-in - // character used to have none and showed a naked model). Identity staleness is a non-issue - // here: char-select reads ride the gateway's PRIVILEGED coordinator (RLS-bypassed), and any - // ZERO/stale owner stamp is corrected by player_login's restamp_owned_data sweep BEFORE the - // first player-scoped (RLS) read of these rows can happen. + crate::items::grant_starter_item(ctx, next_guid, owner)?; Ok(next_guid) } diff --git a/module/src/bridge.rs b/module/src/bridge.rs index 36b7bef56..aee230200 100644 --- a/module/src/bridge.rs +++ b/module/src/bridge.rs @@ -1,4 +1,4 @@ -//! Addon⇄server message bridge (work-item 184) — the UI↔server RPC channel custom features ride. +//! Addon⇄server message bridge, the UI↔server RPC channel custom features ride. //! //! Client→server: the 1.12 client's `SendAddonMessage` is `CMSG_MESSAGECHAT` with language //! `LANG_ADDON` (0xFFFFFFFF) and text `"\t"`. The GATEWAY intercepts those frames @@ -149,9 +149,9 @@ pub struct PartyCommandIssuer { crate::character_owned!(delete, fn sweep_delete_game_party_command_issuer(ctx, character_guid) { ctx.db - .game_party_command_issuer() - .character_guid() - .delete(character_guid); +.game_party_command_issuer() +.character_guid() +.delete(character_guid); }); crate::character_owned!(transfer, fn sweep_transfer_game_party_command_issuer(ctx, character_guid, io) { diff --git a/module/src/character.rs b/module/src/character.rs index d2881402d..c546de996 100644 --- a/module/src/character.rs +++ b/module/src/character.rs @@ -102,36 +102,19 @@ pub struct Character { /// END-appended. [entity] #[default(0i64)] pub death_expire_micros: i64, - /// The instance to enter THIS character into on its next rebuild (work-item 190 slice 1 — - /// always 0 this slice). Set at teleport-accept time (slice 2's dungeon entry) so a relog - /// inside a dungeon puts the rebuilt entity back in the right instance rather than open - /// world; `build_player_entity` reads it, `persist_entity` writes the live entity's - /// `instance_id` back here on logout. `#[default(0u64)]` (typed — u64 needs 8 bytes) + - /// END-appended so `publish` auto-migrates existing rows (migration rule). [entity] + #[default(0u64)] pub pending_instance_id: u64, - /// GM playtest authorization level (work-item 223): `0` = no access to any `.command`; the + /// GM playtest authorization level : `0` = no access to any `.command`; the /// operator-only `gm::set_gm_level` reducer is the only writer. Moderation-facing per-level - /// distinctions beyond "has access at all" are work-item 205's concern, not this one's — every + /// distinctions beyond "has access at all" are's concern, not this one's, every /// `gm_command` today only checks `gm_level != 0`. `#[default(0)]` + END-appended so `publish` /// auto-migrates existing characters to "no GM access" (safe default). Gateway-subscribed /// (`game_character` is in the coordinator's subscription list) → hand-synced in /// `character_type.rs` + widened in `gateway/tests/schema_parity.rs`. #[default(0)] pub gm_level: u8, - /// Released-GHOST state that must survive an entity despawn/rebuild (work-item 226 — the 224 - /// review-finding-#2 landmine): a cross-map graveyard release (`do_repop` on map 36 → a Westfall - /// graveyard on map 0) DESPAWNS the live entity, and the `MSG_MOVE_WORLDPORT_ACK` rebuild goes - /// through `player_login`, whose relog path deleted the corpse and rebuilt `dead: false` — a - /// silent free resurrect. `persist_entity` stamps this from the live entity's actual ghost state - /// (`dead` + `PLAYER_FLAGS_GHOST`) on every NON-logout persist (cross-map hop, stale-entity - /// cleanup) and FORCES it false on a real logout/disconnect (`set_offline` — the established - /// "relog comes back alive" rule, whose corpse delete already lives in `remove_from_world`); - /// `player_login` consumes it: skip the corpse delete + re-apply ghost state onto the rebuilt - /// entity (`world::ghost_restored_fields`). `#[default(false)]` + END-appended so `publish` - /// auto-migrates existing rows (migration rule). Gateway-subscribed (`game_character` is in the - /// coordinator's subscription list) → hand-synced in `character_type.rs` + widened in - /// `gateway/tests/schema_parity.rs` (the `gm_level` precedent). + #[default(false)] pub pending_ghost: bool, /// Rest state (196): logged out in a rest area (inn/city)? Stamped by `persist_entity` from the live @@ -148,7 +131,7 @@ pub struct Character { /// precedent) + END-appended → auto-migrates. #[default(0u64)] pub rested_since_micros: u64, - /// GM playtest GODMODE carried across an entity REBUILD (work-item 289 — the 226 landmine wearing + /// GM playtest GODMODE carried across an entity REBUILD, the 226 landmine wearing /// a GM hat): a CROSS-MAP `.tele` (and every cross-database shard hop, which rides the same /// primitive) DESPAWNS the live entity, and `build_player_entity` rebuilds it from THIS row — so /// before this column, `.god` was silently dropped on arrival and the GM was eaten by the local @@ -162,12 +145,7 @@ pub struct Character { /// `gateway/tests/schema_parity.rs` (the `gm_level`/`pending_ghost` precedent). #[default(false)] pub pending_godmode: bool, - /// GM playtest RUN-SPEED multiplier (basis points, 10000 = 1.0×) carried across an entity rebuild — - /// `pending_godmode`'s twin, same stamp/clear policy (`persisted_gm_playtest`), same reason: - /// `.speed 3` then `.tele valley` used to arrive back at 1×. `#[default(10000)]` (a bare int - /// literal is 4 bytes, which is exactly a u32 — the `WorldEntity::run_speed_mult_bp` precedent) + - /// END-appended so `publish` auto-migrates existing rows to 1× (byte-identical to before this - /// column existed). Gateway-subscribed → hand-synced in `character_type.rs` + widened parity. + #[default(10000)] pub pending_run_speed_mult_bp: u32, /// Bank bag slots bought at a banker (0..=6). Mirrors `WorldEntity.bank_bag_slots` while online diff --git a/module/src/chat.rs b/module/src/chat.rs index c50ab8cd8..cd4866f9c 100644 --- a/module/src/chat.rs +++ b/module/src/chat.rs @@ -50,7 +50,6 @@ pub struct ChatEvent { pub target_guid: u64, } -/// True for the creature broadcast chat types this slice relays. [pure] pub fn is_supported_chat_type(chat_type: u8) -> bool { matches!(chat_type, CHAT_SAY | CHAT_YELL | CHAT_TEXT_EMOTE) } diff --git a/module/src/combat/death.rs b/module/src/combat/death.rs index e8661c90b..f9e6d6352 100644 --- a/module/src/combat/death.rs +++ b/module/src/combat/death.rs @@ -32,7 +32,7 @@ pub(crate) const CORPSE_DECAY_MICROS: i64 = 60_000_000; // a corpse lingers 60s // Soul Shard when its channel's target dies mid-cast. Our Drain Soul import carries no Rust script hook // for that effect, so the grant lives here instead, at the ONE place every creature death already // funnels through. `DRAIN_SOUL_SPELL_ID` is the real vanilla spell id (1120); `SOUL_SHARD_ENTRY` is the -// real vanilla item id (6265, hand-seeded in `seed.rs` since the .import ETL doesn't reliably carry it). +// real vanilla item id (6265, hand-seeded in `seed.rs` since the.import ETL doesn't reliably carry it). pub(crate) const DRAIN_SOUL_SPELL_ID: u32 = 1120; pub(crate) const SOUL_SHARD_ENTRY: u32 = 6265; @@ -499,7 +499,7 @@ pub fn debug_verify_lethal_damage_floor_fixture( Ok(()) } -// Tables' pure formulas/consts and the sibling submodules' re-exports (`roll_money`, `is_engaged`, ...) +// Tables' pure formulas/consts and the sibling submodules' re-exports (`roll_money`, `is_engaged`,...) // are all pulled in from `mod.rs` (`pub use tables::*` + `pub use folds::*`/`engage::*`/`swing::*`) so // every symbol resolves the same as before the split. use super::*; @@ -569,7 +569,7 @@ fn kill_creature_with_attribution( // is mutated (and `target` moved into the update) before they fire. let victim_entry = target.entry; let victim_level = target.level; - let victim_instance = target.instance_id; // for on_creature_death (work-item 228) + let victim_instance = target.instance_id; let current_target_guid = target.target_guid; // The Loot Tag, not the killing blow, owns rewards and corpse eligibility. Resolve it while the // creature is still live. Death dispatch defers its combat-end clear until every hook runs. @@ -648,7 +648,7 @@ fn kill_creature_with_attribution( victim_is_player: false, }, ); - // Encounter kernel (work-item 228): the entry-keyed, instance-stamped creature death — the + // Encounter kernel : the entry-keyed, instance-stamped creature death, the // pet clean-despawn branch above deliberately does NOT fire this (encounters key on wild // creatures). Snapshots were taken before the corpse row was mutated. crate::hooks::fire_on_creature_death( @@ -794,7 +794,7 @@ fn roll_corpse_loot( // A valid Loot Tag rolls quest-only rows with ordinary corpse loot. Visibility and takability // remain per-viewer decisions downstream, so `roll_creature_loot` needs no killing source. let dropped = crate::loot::roll_creature_loot(ctx, target.entry, target_guid); - // Group loot methods (work-item 187 slices 1-4): a GROUPED kill's above-threshold rows may + // Group loot methods : a GROUPED kill's above-threshold rows may // spawn a need/greed roll, below-threshold/round-robin rows get a designated looter stamped, and // above-threshold-under-MASTER rows get restricted to the master — all decided HERE at kill // time (see loot.rs's module doc for why not lazily "at loot-open"). A no-op for an ungrouped @@ -863,11 +863,7 @@ pub(crate) fn kill_player(ctx: &ReducerContext, victim_guid: u64, killer_guid: u // =========================================================================================== // The SHARED damage pipeline [entity] // -// Everything that happens to a target AFTER the damage number is rolled used to exist in four -// near-verbatim copies — the main-hand swing, the off-hand swing, the ranged projectile impact, and -// `spell::apply_target_damage`. They drifted twice (the off-hand ignored Disarm, the -// ranged impact ignored godmode), which is what a copy of a pipeline always eventually does. The -// pipeline now lives here, exactly once, in three stages: + // // 1. `fold_incoming_damage` — the MODIFIER stage: outgoing % → incoming % → absorb → godmode. // Every caller that rolls a fresh number runs it; the ranged IMPACT skips it because its diff --git a/module/src/combat/engage.rs b/module/src/combat/engage.rs index 168940a7e..56edce7f9 100644 --- a/module/src/combat/engage.rs +++ b/module/src/combat/engage.rs @@ -13,7 +13,7 @@ use spacetimedb::{table, ReducerContext, ScheduleAt, Table, Timestamp}; use crate::{game_faction_template, game_world_entity, WorldEntity}; // Tables' pure formulas/consts and the sibling submodules' re-exports (`roll_swing`, `kill_creature`, -// ...) are all pulled in from `mod.rs` (`pub use tables::*` + `pub use folds::*`/`death::*`/`swing::*`) +//...) are all pulled in from `mod.rs` (`pub use tables::*` + `pub use folds::*`/`death::*`/`swing::*`) // — this ALSO brings `tick_melee`/`ranged_impact` into scope for the `MeleeSchedule`/ // `RangedImpactSchedule` tables' `scheduled(..)` macros below to resolve, since those two reducers are // defined in `swing.rs` (mirrors `spell::tables`'s identical cross-file `scheduled(..)` pattern). @@ -318,8 +318,7 @@ pub(crate) fn melee_combatant_guids(ctx: &ReducerContext) -> Vec { #[table( accessor = game_melee_attack, public, - // Perf catalog 1.15: every "who is attacking X" question (disengage, kill_creature's - // still_engaged, is_engaged, combatant_guids, heal threat) used to full-scan this table. + index(accessor = by_target, btree(columns = [target_guid])) )] pub struct MeleeAttack { @@ -334,7 +333,7 @@ pub struct MeleeAttack { /// auto-migrates existing rows (no `-c` wipe). [entity] #[default(0)] pub ranged_spell_id: u32, - /// Work-item 037 (dual wield): the OFF-HAND swing's own clock, independent of `last_swing_ms` — an + /// (dual wield): the OFF-HAND swing's own clock, independent of `last_swing_ms`, an /// off-hand weapon has its own `delay_ms`, so it swings on a different cadence than the main hand. /// 0 = never swung (the next eligible tick swings immediately), same sentinel as `last_swing_ms`. /// Only consulted for a MELEE engagement (`ranged_spell_id == 0`) with a live off-hand weapon @@ -425,15 +424,6 @@ pub struct CombatEvent { } impl CombatEvent { - /// A baseline `CombatEvent` row for `attacker`/`target_guid`, stamped from the attacker's - /// already-fetched [`WorldEntity`] — zero `game_world_entity` lookups. `id`=0, - /// `created_at`=`ctx.timestamp`, the AOI address (`map_id`/`instance_id`/`grid_x`/`grid_y`) copied - /// straight off `attacker`, every other field at its neutral zero/false. Replaces the field-literal - /// plus the four-call `grid_of` copy-paste this used to require at every call site (perf catalog - /// audit, 2026-08-06) — every current call site already has the attacker entity in hand (the swing - /// tick fetches it once up front), so this is the only constructor `CombatEvent` needs; a guid-only - /// `grid_of`-backed variant can be added the day a call site without the entity shows up. A call - /// site overrides only the handful of fields that carry real signal via struct-update syntax. pub(crate) fn signal_at( ctx: &ReducerContext, attacker: &WorldEntity, @@ -743,10 +733,7 @@ pub(crate) fn apply_start_ranged_attack( if target_guid == attacker.guid { return Err("cannot attack self".to_string()); } - // The equipped ranged weapon, read ONCE for the whole reducer (it used to be fetched three times: - // here, for the ammo gate, and again for the wind-up seed) — Auto Shot / Shoot are impossible - // bare-handed. Checked BEFORE the shared target gate, preserving the order in which a command that - // is invalid on both counts reports its reason. + let ranged_weapon = equipped_ranged_weapon(ctx, attacker.guid) .ok_or_else(|| "no ranged weapon equipped".to_string())?; let target = validate_attack_target(ctx, &attacker, target_guid)?; @@ -788,10 +775,7 @@ pub(crate) fn apply_start_ranged_attack( ) { return Err("target not in line of sight".to_string()); } - // A launcher (bow/gun/crossbow) with an empty quiver rejects at activation — vanilla's - // SPELL_FAILED_NO_AMMO red error — instead of arming, sending a clean START, and silently - // cancelling ~500ms later when the first shot's find_ammo comes up empty (review find). - // Wands consume nothing. The mid-loop run-out keeps the swing tick's teardown+cancel. + use crate::items::weapon_subclass as ws; if matches!(ranged_weapon.3, ws::BOW | ws::GUN | ws::CROSSBOW) && find_ammo(ctx, attacker.guid).is_none() diff --git a/module/src/combat/folds.rs b/module/src/combat/folds.rs index 49900f49c..71786cd0e 100644 --- a/module/src/combat/folds.rs +++ b/module/src/combat/folds.rs @@ -627,11 +627,6 @@ pub(crate) fn roll_swing( roll_swing_with_range(ctx, attacker, target, range) } -/// The shared core of `roll_swing`: identical attack-table roll + mitigation, but takes the `[min, max]` -/// damage range EXPLICITLY instead of deriving it from `attacker`'s equipped main-hand -/// (`swing_range_ctx`). Lets the off-hand swing in `resolve_swing` roll through the SAME shared attack -/// table + armor/block math over the off-hand's OWN (penalized) range, instead of re-deriving the -/// main-hand range. `roll_swing` is a thin wrapper that passes `swing_range_ctx(ctx, attacker)`. pub(crate) fn roll_swing_with_range( ctx: &ReducerContext, attacker: &WorldEntity, diff --git a/module/src/combat/swing.rs b/module/src/combat/swing.rs index 15260b862..49f52655b 100644 --- a/module/src/combat/swing.rs +++ b/module/src/combat/swing.rs @@ -15,7 +15,7 @@ use crate::{ }; // Tables' pure formulas/consts and the sibling submodules' re-exports (`roll_swing`, `apply_hit`, -// `enter_combat`, ...) are all pulled in from `mod.rs` (`pub use tables::*` + `pub use +// `enter_combat`,...) are all pulled in from `mod.rs` (`pub use tables::*` + `pub use // folds::*`/`death::*`/`engage::*`) so every symbol resolves the same as before the split. use super::*; @@ -29,7 +29,7 @@ use super::*; /// A leash pass runs first: a creature whose pursuit deadline expired with the target away from the /// remembered refresh position evades. /// -/// Work-item 229 (per-instance ticks): DELIBERATELY LEFT GLOBAL — one schedule row, no `instance_id` +/// (per-instance ticks): DELIBERATELY LEFT GLOBAL, one schedule row, no `instance_id` /// scoping. Verified same-instance-by-construction: every pass here outer-loops `game_melee_attack`, /// and a melee row's attacker/target pair shares one instance at ARM time on every arming path — /// `apply_start_attack`/`apply_start_ranged_attack` reject a cross-instance target explicitly, this @@ -186,20 +186,6 @@ fn aggro_pass(ctx: &ReducerContext) { /// swing count). Runs on its OWN clock — the off-hand weapon's own `delay_ms`, tracked in /// `MeleeAttack::last_offhand_swing_ms` — so it fires at a different cadence than the main hand and is /// NOT gated by the main-hand swing-timer `continue` in `resolve_swing`. -/// -/// Its own function rather than a branch of `fire_melee_swing`'s locals: it re-fetches its OWN fresh -/// `target` row (the main-hand swing may go on to damage or even kill the SAME target this tick) and -/// persists its own `last_offhand_swing_ms` stamp directly — it does not rely on the main hand's -/// tail-of-loop stamp, which that path's various early returns (corpse/CC/lethal) may skip. -/// -/// The DAMAGE half is not duplicated at all any more: the same -/// `fold_incoming_damage` → `final_damage` → event → [`apply_hit`] the main-hand swing and ranged -/// impact use, over the off-hand's AP-scaled range reduced by `apply_offhand_penalty` (vanilla's 50% -/// dual-wield penalty). What stays off-hand-specific is only what vanilla makes MAIN-HAND-only: it does not arm -/// the Overpower/Revenge react windows, does not fire the seal / next-swing procs, and does not wear -/// durability (no separate off-hand durability model yet — a deliberate simplification, like the -/// main-hand's own DURABILITY_WEAR_CHANCE_PCT tuning). It also leaves the IN_COMBAT stamp to the main -/// hand's tail (the engagement is shared). fn resolve_offhand_swing( ctx: &ReducerContext, attacker_guid: u64, @@ -282,9 +268,6 @@ fn resolve_offhand_swing( return; } - // Persist the off-hand's own clock — re-fetch fresh (the engagement row may not have been touched - // by anything above, but read-your-writes is cheap insurance and matches the file's established - // "re-fetch a fresh mutable copy" pattern for a row written mid-function). if let Some(mut fresh) = melee.attacker_guid().find(attacker_guid) { fresh.last_offhand_swing_ms = now_ms; melee.attacker_guid().update(fresh); @@ -293,15 +276,6 @@ fn resolve_offhand_swing( /// Pass 2 — swings. For each engagement (re-read, since aggro may have added rows), run the SHARED /// ELIGIBILITY GATE and, if it passes, fire the swing. -/// -/// This function is the gate and nothing else: both parties still in the world and not -/// corpses, the attacker not crowd-controlled and not routing, the ranged weapon still equipped, the -/// vanilla movement rule for a ranged loop, [`swing_blocked`]'s positional checks, the off-hand's -/// independent second stream, and the swing timer. What a swing then DOES lives in one of two -/// resolvers — [`fire_melee_swing`] or [`fire_ranged_shot`] — because a melee swing and a ranged shot -/// share their gate and almost nothing else. They used to be one ~370-line body with a mid-loop -/// `continue` fork, four repetitions of the ranged teardown rule, and `ranged.is_none()` sprinkled -/// through every melee-only proc. fn resolve_swing(ctx: &ReducerContext) { let now_ms = (ctx.timestamp.to_micros_since_unix_epoch() / 1000) as u32; let melee = ctx.db.game_melee_attack(); @@ -420,12 +394,6 @@ fn resolve_swing(ctx: &ReducerContext) { } } - // POSITIONAL eligibility — max range, ranged minimum range, line of sight, facing — all four - // in ONE gate. They used to be four separate `if ... { if ranged_due { delete } continue }` - // blocks, i.e. four copies of the teardown rule ("a blocker on a DUE ranged shot INTERRUPTS the - // auto-repeat loop; a melee blocker just waits for the next tick") — one per gate, each free to - // drift. `swing_blocked` is side-effect-free and short-circuits in the same order, so this is - // behaviour-identical; the teardown now exists once. let (dx, dy, dz) = ( target.x - attacker.x, target.y - attacker.y, @@ -439,38 +407,20 @@ fn resolve_swing(ctx: &ReducerContext) { continue; } - // Off-hand swing (dual wield): a MELEE engagement (never ranged — Auto Shot/wand - // have no off-hand analog) whose attacker has a live off-hand WEAPON rolls a SECOND, independent - // swing on the off-hand's OWN clock (`last_offhand_swing_ms`), gated on the SAME in-range check - // just above but NOT on the main-hand's swing timer below — so the off-hand still fires on a tick - // where the main hand is mid-cooldown. Self-contained: re-fetches its own fresh `target` (the - // main-hand branch below may go on to kill/damage the SAME target this tick) and writes its own - // event + damage + rage/threat/skill side effects, mirroring the main-hand shape. No-op (and - // `atk.last_offhand_swing_ms` stays untouched) for a ranged engagement, an unarmed/shielded - // off-hand, or a timer not yet elapsed — so a main-hand-only attacker's tick is unchanged - // (baseline-safe). if ranged.is_none() { resolve_offhand_swing(ctx, atk.attacker_guid, atk.target_guid, &attacker, now_ms); if melee.attacker_guid().find(atk.attacker_guid).is_none() { continue; // a lethal off-hand Duel hit removed both participants' attack rows } - // The off-hand swing may have killed/disengaged `target` (or, on a lethal PLAYER off-hand - // hit, zeroed its health) via its own writes — re-sync the in-hand snapshot the main-hand - // path below reads/writes so it never clobbers the off-hand's kill with a stale pre-swing - // copy. A miss/no-off-hand-weapon tick leaves `target` byte-identical (re-fetch is a no-op). + match entities.guid().find(atk.target_guid) { Some(refreshed) => target = refreshed, None => { - // The off-hand's killing blow fully removed the target (a PET death delete, or a - // creature corpse this loop's earlier existence guard would otherwise have caught). melee.attacker_guid().delete(atk.attacker_guid); continue; } } if target.dead { - // The off-hand swing was the killing blow — the main-hand's own swing this tick is - // moot (there's nothing left to hit), so stop here exactly like the top-of-loop corpse - // guard would on the NEXT tick, just one tick earlier for this row. continue; } } @@ -487,13 +437,6 @@ fn resolve_swing(ctx: &ReducerContext) { continue; } - // FIRE. Everything above is the SHARED eligibility gate — existence, corpse, CC, rout, the - // ranged weapon, the movement rule, position, the off-hand's independent stream, and the swing - // timer. What a fired swing then DOES splits cleanly in two: a melee swing (the full - // attack table, the seal / next-swing / react procs, an instant hit) and a ranged shot (a - // reduced table, ammo, and a projectile whose damage lands on a scheduled impact). Each - // resolver owns its own wire event and its own `last_swing_ms` stamp, and both apply damage - // through the one shared `apply_hit` pipeline. match ranged { Some(weapon) => fire_ranged_shot( ctx, @@ -734,7 +677,7 @@ fn fire_melee_swing( // (never at queue time). is_completion=true rides the timed-completion relay shape, which is // exactly the vanilla on-next-swing fire: CAST_RESULT(OK) to the caster (releases the client's // pending cast = un-lights the button) + SMSG_SPELL_GO alone (no second START) + the yellow - // "Heroic Strike hits ..." damage log carrying the WHOLE landed swing (weapon roll + bonus, + // "Heroic Strike hits..." damage log carrying the WHOLE landed swing (weapon roll + bonus, // post-mitigation; the seal's portion stays on its own line). A MISSED swing still emits the // row (damage 0, no log) — the GO must fire or the client's button stays lit forever. if swing_is_spell { @@ -752,7 +695,6 @@ fn fire_melee_swing( } // 114 FIX (b): the seal's holy portion — a log-only row (is_proc_log): the gateway sends ONLY // SMSG_SPELLNONMELEEDAMAGELOG named after the seal spell (yellow "Seal of Righteousness hits - // ... Holy"), never START/GO (nothing casts; the seal aura is already up — vanilla shape). if projected_seal > 0 { ctx.db.game_spell_cast_event().insert(SpellCastEvent { target_guid, @@ -968,12 +910,7 @@ pub fn ranged_impact(ctx: &ReducerContext, shot: RangedImpactSchedule) { if !crate::combat::may_harm(ctx, &attacker, &target) { return; // Duel or faction authorization changed while the projectile was in flight } - // GM playtest godmode (work-item 223's `.god`): the ONE modifier this path re-evaluates at impact. - // The rest of the chain (outgoing %, damage-taken %, absorb) was folded and FROZEN at launch by - // `fire_ranged_shot`, so the delayed damage LOG equals what actually lands — but godmode can be - // toggled on DURING the arrow's flight, and re-checking only the frozen value would let a delayed - // arrow land damage that a melee swing at the same instant would zero. `false` for a - // non-godmode target, so this is byte-identical for them. + let dmg = if target.godmode { 0 } else { shot.damage }; let damage = final_damage(ctx, shot.target_guid, dmg); // The SHARED pipeline — the same one the main-hand and off-hand swings route through, so a diff --git a/module/src/combat/tables.rs b/module/src/combat/tables.rs index 234fd3edd..07031ea2d 100644 --- a/module/src/combat/tables.rs +++ b/module/src/combat/tables.rs @@ -215,12 +215,6 @@ pub fn melee_attack_power(strength: u32, level: u32) -> u32 { const CLASS_HUNTER: u8 = 3; const CLASS_ROGUE: u8 = 4; -/// Class-specific melee attack power. Agility-based classes (Rogue/Hunter) use the -/// vanilla `level*2 + Str + Agi - 20` curve so AGILITY feeds the swing — without this a leveling Rogue -/// gained nothing from their primary stat. Every other class (Warrior/Paladin + the default) keeps the -/// Str-class `melee_attack_power` curve, so their AP is byte-identical to before (baseline-safe). Clamped -/// ≥0. Known limitation: pure casters technically use `Str-10` in vanilla, but that's an -/// unrequested balance change — left on the Str-class default until a caster-melee pass needs it. pub fn melee_attack_power_for(class: u8, strength: u32, agility: u32, level: u32) -> u32 { match class { CLASS_ROGUE | CLASS_HUNTER => (level * 2 + strength + agility).saturating_sub(20), @@ -275,12 +269,6 @@ pub fn weapon_swing_range_ap(ap: u32, dmg_min: u32, dmg_max: u32, delay_ms: u32) (dmg_min + bonus, dmg_max + bonus) } -/// The vanilla dual-wield OFF-HAND penalty: an off-hand swing's `[min, max]` (already the AP-scaled -/// off-hand weapon range from `weapon_swing_range_ap`) lands at 50% of its unpenalized damage, floored -/// at 1 so a very low-roll off-hander never swings for 0. Pure — unit-tested. `resolve_swing`'s off-hand -/// roll applies this to the off-hand's own AP-scaled range BEFORE the shared -/// attack-table roll (`roll_swing_with_range`), so crit/glancing/armor still multiply off the already- -/// halved base — the same shape as `weapon_swing_range_ap`'s main-hand range feeding `roll_swing`. pub fn apply_offhand_penalty(min: u32, max: u32) -> (u32, u32) { ((min / 2).max(1), (max / 2).max(1)) } @@ -377,12 +365,6 @@ pub fn armor_mitigation_pct(armor: u32, attacker_level: u32) -> u32 { (armor * 100 / denom).min(75) } -/// Magic-school resistance reduction as a whole-number percent (vanilla average-resist): `75 × -/// resistance / (caster_level × 5)`, capped at 75%. Resistance beyond the per-level cap (`caster_level × -/// 5`) doesn't help — that's the 75% ceiling. `resistance == 0` (no resist aura — every unit today) or a -/// 0-level caster → 0%, so a magic hit is byte-identical to before (baseline-safe). The MAGIC twin of -/// `armor_mitigation_pct` (physical) — both live here as the canonical mitigation formulas; the -/// spell-damage path folds this in (`spell::apply_resistance`). Pure — unit-tested. /// /// PROVENANCE: the linear `75 × resist / (level×5)` average-resist with a 75% cap is the widely-used /// 1.12 community approximation (the real engine rolls partial-resist bands whose *average* this models) diff --git a/module/src/config.rs b/module/src/config.rs index f005ae2a4..f295cbcd5 100644 --- a/module/src/config.rs +++ b/module/src/config.rs @@ -1,8 +1,3 @@ -//! Static-data tables: the realm list, starting positions, per-race display info, the legal -//! race/class combos, and the client `AreaTable.dbc`/`AreaTrigger.dbc` zone/trigger data (work-item -//! 209). Categories follow `docs/schema.md`. Data definitions, plus the one operator-gated writer -//! for the realm address — it lives beside the row it writes rather than with the GM reducers. - use spacetimedb::{log, reducer, table, ReducerContext, Table}; use crate::helpers::require_operator; @@ -52,7 +47,7 @@ pub struct ServerConfig { #[primary_key] pub id: u32, // singleton: always 0 pub xp_rate: f32, - // END-APPENDED (work-item 243): nav-grid consumption gate (chase pathing + aggro/cast/melee + // END-APPENDED : nav-grid consumption gate (chase pathing + aggro/cast/melee // LoS). Default ON since 244 passed (benchmark: nav cost indistinguishable; live: all four // wall/fence/chase/hold-fire scenarios). A world WITHOUT nav data imported behaves exactly // as before either way (missing chunk = no obstacles known). Toggle: `debug_set_nav_enabled` @@ -62,7 +57,7 @@ pub struct ServerConfig { // END-APPENDED: does THIS database host dungeon-instance POPULATIONS? Default `true` // = every single-database realm behaves exactly as it always has (the portal spawns the dungeon // where the player is standing). Set `false` on the OPEN-WORLD shard of a multi-database - // deployment (spec Phase A): `create_instance_with_id` then files the `game_instance` row + + // deployment: `create_instance_with_id` then files the `game_instance` row + // binding as a LEASE and spawns nothing, so the world writer never pays for a dungeon whose run // happens on another database — the instances shard, where this stays `true`, spawns the // population when the gateway mirrors the id there via `ensure_instance`. @@ -70,7 +65,7 @@ pub struct ServerConfig { // Deliberately a WORLD POLICY, not a shard id: the module still knows nothing about shards; // it only knows whether it hosts instance populations — the `nav_enabled` precedent. // Operator-set, like `nav_enabled`: `UPDATE game_config SET hosts_instances = false WHERE id = 0`. - // Deliberate simplification: one SQL line in the Phase A runbook rather than a gateway→module + // Deliberate simplification: one SQL update rather than a gateway→module // policy push. Ceiling: an operator who forgets it gets today's behavior (the dungeon spawns // on the world shard and is evicted after the transfer) — degraded, never broken. Upgrade // path: the gateway derives it from the shard map and asserts it at startup. @@ -84,17 +79,7 @@ pub struct ServerConfig { // Operator-set like `nav_enabled`: `UPDATE game_config SET bots_idle = true WHERE id = 0`. #[default(false)] pub bots_idle: bool, - // END-APPENDED (decision): exact per-cell vmap collision-triangle consumption - // gate — the LoS/collision ray queries in `vmap::los_ray`/`vmap::collision_ray`, and - // `nav::has_los`'s consumers (aggro/assist/creature-casts/engage/swing-gate/caster hold-range) - // plus Blink's collision clamp. Default OFF: the standard import pipeline - // (`importer/scripts/import-world.sh`) has no vmap step, so a normally-provisioned world has - // ZERO `game_vmap_chunk` rows for every map — and the missing-chunk contract (no row = "no - // obstruction known here") then reads as "every ray is clear" MAP-WIDE, not just per-cell. - // That's fine for a partially-covered map (the intended degrade) but wrong as a global - // default while vmap import is a manual, unwired path. Flip per-map only after - // `importer --vmap` has actually populated `game_vmap_chunk` for it. Toggle: - // `debug_set_vmap_enabled` or `UPDATE game_config SET vmap_enabled = true WHERE id = 0`. + #[default(false)] pub vmap_enabled: bool, // END-APPENDED: let vmap-DERIVED nav coverage join path planning (`nav::fetcher` merges it @@ -148,14 +133,6 @@ pub struct CharBaseInfo { pub class: u8, } -/// One `AreaTable.dbc` row — a zone OR subzone (`parent_area_id` distinguishes: 0 = top-level zone -/// like Elwynn/Westfall; nonzero = a subzone whose value points at another `game_area.id`, its -/// enclosing zone). Loaded from the client `AreaTable.dbc` by the importer's `--dbc` mode (work-item -/// 209; see `importer/src/dbc.rs::area_sql`). Consumers: rest-state zone naming (196), exploration -/// XP's zone/subzone resolution (200), and `terrain::zone_id_at`'s one-hop subzone→zone chase for -/// graveyard + fishing zone resolution (209/375). `flags`/`faction_group` are the raw -/// `AreaTable.dbc` bitmasks (city/rest-state, PvP sanctuary) — undecoded here; a consumer decodes -/// what it needs. No Timestamp → plain SQL. [static] #[table(accessor = game_area, public)] pub struct GameArea { #[primary_key] @@ -172,7 +149,7 @@ pub struct GameArea { /// One `AreaTrigger.dbc` row — a trigger volume (a sphere via `radius`, or a box via /// `box_length`/`box_width`/`box_height`/`box_yaw`; vanilla trigger definitions use one shape or the /// other, never both). Loaded from the client `AreaTrigger.dbc` by the importer's `--dbc` mode -/// (work-item 209; see `importer/src/dbc.rs::area_trigger_sql`). The geometric half of inn triggers +/// (see `importer/src/dbc.rs::area_trigger_sql`). The geometric half of inn triggers /// (196 — "make this inn your home" needs the player standing inside the inn's trigger volume), /// dungeon entrances (190), and quest explore objectives. No Timestamp → plain SQL. [static] #[table(accessor = game_area_trigger, public)] diff --git a/module/src/corpse.rs b/module/src/corpse.rs index 38ab6597a..e2cc2f51c 100644 --- a/module/src/corpse.rs +++ b/module/src/corpse.rs @@ -109,7 +109,7 @@ pub(crate) fn corpse_appearance_bytes( player_bytes: u32, player_bytes_2: u32, ) -> (u32, u32) { - // `unpack4` returns the four little-endian bytes (.0 = bits 0-7 … .3 = 24-31); take the same + // `unpack4` returns the four little-endian bytes (.0 = bits 0-7 ….3 = 24-31); take the same // bytes the inline mask/shifts did. race/gender from `unit_bytes_0` bytes 0/2; skin/face/hair // from `player_bytes` bytes 0-3; facialhair from `player_bytes_2` byte 0. let (race, _, gender, _) = unpack4(unit_bytes_0); @@ -160,13 +160,13 @@ pub struct Corpse { #[default(false)] pub is_bones: bool, - // Which instance the death happened in (work-item 190 slice 2): stamped from the dying + // Which instance the death happened in : stamped from the dying // player's own entity in `do_repop`; 0 = open world (every existing row auto-migrates to 0). // Gates reclaim (below) and the gateway's corpse CREATE relay by viewer instance; the instance // reap deletes any corpse left inside (the ghost's outcome is then spirit-healer-only — // vanilla's expired-corpse rule). END-appended + `#[default(0u64)]` (danger-zones §2). // GATEWAY-SUBSCRIBED table → `gateway/src/stdb/bindings/corpse_type.rs` + the - // `schema_parity.rs` manifest hand-synced in the SAME change (playbook failure-mode #1). + // `schema_parity.rs` manifest hand-synced in the SAME change (playbook failure-mode). #[default(0u64)] pub instance_id: u64, } diff --git a/module/src/creatures/ai.rs b/module/src/creatures/ai.rs index 6704a7c66..6a28d7818 100644 --- a/module/src/creatures/ai.rs +++ b/module/src/creatures/ai.rs @@ -181,12 +181,12 @@ pub fn aggro_radius(creature_level: u32, player_level: u32, template_aggro_range } // =========================================================================================== -// Active-cell activation radius (work-item 230: grid-activation — only cells near players tick) +// Active-cell activation radius (grid-activation, only cells near players tick) // [pure] // =========================================================================================== /// The combat activation radius (yards) around a player within which a creature must be treated as -/// ACTIVE for the aggro/assist passes this tick, rather than dormant. Two ceilings combine (the larger +/// active for the aggro/assist passes this tick, rather than dormant. Two ceilings combine (the larger /// wins), then the assist margin is added on top: /// - `MAX_AGGRO_RADIUS` (45yd) — the vanilla level-scaled proximity-aggro ceiling that covers every /// creature relying on the DEFAULT scaling (every imported cmangos creature: the ETL never sets @@ -304,13 +304,6 @@ pub fn feared_flee_step(cx: f32, cy: f32, sx: f32, sy: f32, dist: f32, rand: u32 (cx + a.cos() * dist, cy + a.sin() * dist) } -// =========================================================================================== -// Chase (vanilla creature AI: an engaged mob closes on an out-of-range target) [pure helper] -// =========================================================================================== - -/// Squared melee reach — a creature this close (or closer) is in range to swing and must NOT chase. -/// Aliases combat's `MELEE_RANGE_SQ` ((5 yd)²) directly — a single source of truth, so this pass and -/// the swing pass agree on the boundary by construction (no lockstep-by-comment). pub(crate) const CHASE_MELEE_SQ: f32 = crate::combat::MELEE_RANGE_SQ; /// Squared chase cutoff — a target beyond this is too far to pursue. An engaged creature chases while its @@ -429,13 +422,13 @@ pub(crate) const SENSE_EVERY_N_TICKS: i64 = 8; /// The target SENSE period in micros (~4s) — `MOVE_TICK_MICROS × SENSE_EVERY_N_TICKS`. A tick row of /// ANY cadence quantizes its sensing passes to roughly this period (see -/// `is_sense_tick_for_interval`), so a tight per-instance tick (work-item 229) smooths MOVEMENT +/// `is_sense_tick_for_interval`), so a tight per-instance tick smooths MOVEMENT /// latency without also multiplying the expensive sensing scans or the tick-quantized effect rates /// (wander hop chance, aggro re-checks) that assume the ~4s cadence. pub(crate) const SENSE_PERIOD_MICROS: i64 = MOVE_TICK_MICROS * SENSE_EVERY_N_TICKS; /// Is this tick a "sensing" tick (run the ~4s passes)? Generalized to a tick row of ANY interval -/// (work-item 229 — per-instance tick rows carry their own `ScheduleAt::Interval`): one firing in +/// (per-instance tick rows carry their own `ScheduleAt::Interval`): one firing in /// every `SENSE_PERIOD_MICROS / interval` firings is a sense tick, so the sensing cadence stays ~4s no /// matter how fast the row fires. At the seeded default (`interval == MOVE_TICK_MICROS`) the divisor /// is exactly `SENSE_EVERY_N_TICKS` (one sense tick in 8). An interval LONGER than the sense period @@ -453,12 +446,6 @@ pub fn is_sense_tick_for_interval(now_micros: i64, interval_micros: i64) -> bool (now_micros / interval) % every_n == 0 } -/// One tick's worth of SECONDS for a row firing every `interval_micros` — what the per-firing STEP -/// passes (chase/return/flee/fear-flee) multiply speed by, so a creature's NET speed is -/// cadence-invariant: a 100ms per-instance tick emits 5× as many legs each 1/5 the length, not 5× -/// the speed (the multiplied-movement bug the work-item 229 review hunts for). At the default -/// interval this is EXACTLY `MOVE_TICK_SECS` (500_000/1_000_000 = 0.5, exact in f32) — byte-identical -/// legs. Same non-positive clamp as `is_sense_tick_for_interval`. Pure — unit-tested. pub fn tick_secs_for_interval(interval_micros: i64) -> f32 { let interval = if interval_micros > 0 { interval_micros @@ -476,14 +463,6 @@ pub fn fear_step_for_tick(tick_secs: f32) -> f32 { FEAR_STEP * (tick_secs / MOVE_TICK_SECS) } -/// The ACTUAL seconds between two sense ticks of a row firing every `interval_micros` — i.e. -/// `every_n × interval` with the same `every_n = max(SENSE_PERIOD_MICROS / interval, 1)` floor as -/// `is_sense_tick_for_interval` (the two MUST stay in lockstep: this is the leg span a sense-tick -/// pass must cover to bridge to the NEXT sense tick). NOT always ~4s: the floor makes every firing -/// of a slower-than-half-the-period row a sense tick, so a 2.5s row's sense period is 2.5s, not 4s -/// — the pet follow pass hardcoding 4s emitted OVERLAPPING legs there (~1.6× closing speed, 229 -/// review). At the default 500ms interval this is exactly `MOVE_TICK_SECS × SENSE_EVERY_N_TICKS` -/// (4.0). Same non-positive clamp. Pure — unit-tested. pub fn sense_period_secs_for_interval(interval_micros: i64) -> f32 { let interval = if interval_micros > 0 { interval_micros @@ -495,32 +474,13 @@ pub fn sense_period_secs_for_interval(interval_micros: i64) -> f32 { } // =========================================================================================== -// Per-instance tick scoping (work-item 229) [pure] +// Per-instance tick scoping [pure] // =========================================================================================== // -// HONEST BOUND (from the work item — repeat it wherever this machinery is documented): SpacetimeDB -// serializes ALL reducers on ONE commit stream, so per-instance tick rows are LATENCY SMOOTHING + -// WORK AVOIDANCE, **NOT parallel throughput**. A dedicated row lets one instance tick tighter (or, -// later, slower/paused) and lets every row scan ONLY its own instances — it never adds CPU in -// parallel, and every extra firing TAXES the shared serialized stream (10 instances at 100ms = 100 -// extra transactions/sec preempting player actions). Tight cadences are a knob to use sparingly. - -/// Sentinel `instance_id` for a `game_creature_move_schedule` row meaning GLOBAL / catch-all: the row -/// covers every instance WITHOUT a dedicated row of its own — normally including instance 0 (the -/// open world), but the partition rule has no special case: arming a dedicated row for instance 0 -/// (permitted, if odd) moves the open world's SCOPED passes onto that row, while the global -/// due-time passes stay on the catch-all firing — and the 230/233 catch-all evidence log lines -/// would then read ~0 creatures (they describe the catch-all's scoped work; 229 review). u64::MAX — -/// no real instance ever gets this id (instance ids are allocated small; and even an entity -/// teleported to a pathological instance u64::MAX would still be covered by the catch-all, never -/// stranded). The schema default on `CreatureMoveSchedule.instance_id` is this value WRITTEN AS A -/// LITERAL (`18_446_744_073_709_551_615u64` — the `#[default]` macro wants a literal expression); -/// the `global_tick_instance_sentinel_is_u64_max` test pins THIS CONST to u64::MAX, but nothing can -/// programmatically tie it to the `#[default]` attribute literal in tick.rs (migration metadata, -/// not a Rust `Default`) — editing THAT literal alone reds no test; the schema doc points back here. + pub(crate) const GLOBAL_TICK_INSTANCE: u64 = u64::MAX; -/// Which instances one FIRING of `tick_creatures` covers (work-item 229). Built per firing from the +/// Which instances one FIRING of `tick_creatures` covers. Built per firing from the /// firing row's `instance_id` + the full (tiny) schedule-row set: /// - a DEDICATED row (`instance_id != GLOBAL_TICK_INSTANCE`) covers EXACTLY its instance; /// - the CATCH-ALL row (`instance_id == GLOBAL_TICK_INSTANCE`) covers every instance EXCEPT those @@ -564,7 +524,7 @@ impl TickScope { /// Does THIS firing cover `instance_id`? The one predicate every scoped pass gates on. With only /// the seeded catch-all row (no dedicated rows) this is `true` for EVERY instance — the - /// equivalence case: the pass visits the identical candidate set it did before work-item 229. + /// equivalence case: the pass visits the identical candidate set it did before. pub(crate) fn covers(&self, instance_id: u64) -> bool { match self { TickScope::CatchAll { dedicated } => !dedicated.contains(&instance_id), @@ -711,7 +671,7 @@ mod tests { } // ========================================================================================= - // Active-cell activation math (work-item 230) + // Active-cell activation math // ========================================================================================= #[test] @@ -752,13 +712,6 @@ mod tests { assert_eq!(aggro_override_cutoff(ASSIST_RADIUS - 1.0), 0); } - /// Work-item 230's engaged-creature-never-dormant rule ("a player could drag one far away") only - /// holds if a creature glued to a player by combat can never wander past the active-cell radius - /// before the active set would have covered it anyway. The chase and rout phases don't consult - /// the active set at all (see their doc comments) — that's the primary guarantee — but this - /// pins the geometric invariant too: the chase cutoff (the farthest a target can be and still be - /// pursued) must stay inside `combat_active_radius`, so even a hypothetical future re-gate could - /// never sleep a still-engaged creature out from under its target. /// /// Distance no longer ends a fight, so the cutoff is no longer in lockstep with an evade constant: it /// IS the active-cell radius, which is what keeps an engaged creature pursuing while its engagement @@ -774,7 +727,7 @@ mod tests { ); } - /// Work-item 230's "mid-leg flee/return either finishes or freezes coherently" requirement: this + ///'s "mid-leg flee/return either finishes or freezes coherently" requirement: this /// repo picks FREEZE (dormancy simply skips the pass, touching nothing). That choice is only /// coherent because `chase_step` (the shared return/chase step primitive) is a pure function of the /// CURRENT position and target — no elapsed-time or step-count argument — so pausing for any number @@ -801,9 +754,6 @@ mod tests { assert_eq!(next_after_resume, next_if_uninterrupted); } - /// The walk home has no upper bound on displacement: the return pass re-steps ONE tick of run per - /// firing (`nav_step` → this primitive), so a creature left far out chains legs to `RETURN_LEASH_SQ` - /// instead of single-legging and stalling. #[test] fn a_creature_displaced_far_from_home_steps_all_the_way_back() { let (home_x, home_y) = (-100.0_f32, 250.0_f32); @@ -908,7 +858,7 @@ mod tests { assert_eq!(scale_health_for_rank(2_000_000_000, 4), 3_600_000_000); // ×1.8 < u32::MAX } - /// Schema shape guard (work-item 214, follows `loot.rs`'s `new_loot_family_tables_construct_with_ + /// Schema shape guard, follows `loot.rs`'s `new_loot_family_tables_construct_with_ /// the_documented_shape` convention): `game_creature_family` constructs with named fields exactly /// like the importer's positional SQL INSERT column list (`family_id,name,pet_food_mask, /// pet_talent_type,category`) — a compile-time guard against a silently reordered/renamed column. @@ -1158,8 +1108,6 @@ mod tests { assert!(approx(wounded_slow_factor(0, 0), 1.0)); } - /// Mutation target: an `||` instead of `&&`, or dropping the z arm, and a corrupted coordinate - /// reaches the entity row again — where it is invisible to the tick and unshakeable in combat. #[test] fn finite_point_rejects_every_non_finite_coordinate() { assert!(finite_point(-8949.9, -132.4, 83.5)); @@ -1169,9 +1117,6 @@ mod tests { assert!(!finite_point(f32::NAN, f32::NAN, f32::NAN)); } - /// A godmoded GM is invisible to creature AI. Mutation target: drop the `!godmode` arm and a GM - /// standing in a starting zone re-collects the entire local creature population, because nothing - /// ever kills them and nothing ever leashes home. #[test] fn godmode_and_dead_players_are_not_aggro_candidates() { assert!( @@ -1223,7 +1168,7 @@ mod tests { } // ========================================================================================= - // Work-item 229 — per-instance tick scope + cadence-generalized sense/step math + //, per-instance tick scope + cadence-generalized sense/step math // ========================================================================================= /// Pins the sentinel to u64::MAX — the schema `#[default(18_446_744_073_709_551_615u64)]` on @@ -1261,8 +1206,7 @@ mod tests { scope.covers(0), "the catch-all covers instance 0 (the open world)" ); - // THE ANTI-STRANDING CASE the review hunts for: an entity teleported (224) into instance 3, - // which has NO game_instance row and NO dedicated tick row — still covered by the catch-all. + assert!( scope.covers(3), "an instance with no row of its own is covered by the catch-all" @@ -1298,7 +1242,7 @@ mod tests { } } - /// The work-item 233 equivalence-spec pattern, applied to 229: with ONLY the seeded catch-all row + /// The equivalence-spec pattern, applied to 229: with ONLY the seeded catch-all row /// and every entity at instance 0 (today's world), the scope filter admits the IDENTICAL visit /// set — the passes behave byte-identically to the pre-229 code (the gate is `covers() == true` /// for every candidate, and the global passes still run). @@ -1396,10 +1340,6 @@ mod tests { ); } - /// The sense-period span (pet follow legs) must equal the ACTUAL spacing between sense ticks of - /// the row — `every_n × interval` with the same floor as `is_sense_tick_for_interval` — or a - /// sense-tick pass emits overlapping legs (the 229-review pet bug: a 2.5s row senses EVERY - /// firing, so a hardcoded-4s leg re-emitted every 2.5s closed at ~1.6× run). #[test] fn sense_period_matches_actual_sense_tick_spacing() { // Default 500ms row: exactly the old 4s (byte-identical pet legs). diff --git a/module/src/creatures/cycle/harness.rs b/module/src/creatures/cycle/harness.rs index 6bb00c352..e4a0ad881 100644 --- a/module/src/creatures/cycle/harness.rs +++ b/module/src/creatures/cycle/harness.rs @@ -4490,10 +4490,6 @@ fn a_lead_leg_is_stopped_where_it_renders_once_the_victim_stops() { ); } -/// The rule the production carrier has by construction, made visible: one `game_creature_spline` -/// row per creature, and a subscriber sees only a transaction's net change. Written as a pair of -/// direct sink calls — the very pair `stand_and_face` used to make — because no scenario can reach -/// the forbidden write any more, and a rule nothing proves detects anything is not a rule. #[test] #[should_panic(expected = "wrote two carrier rows in one firing")] fn the_fake_refuses_a_second_carrier_row_for_one_creature_in_one_firing() { diff --git a/module/src/creatures/cycle/mod.rs b/module/src/creatures/cycle/mod.rs index f22b2c1ed..025161248 100644 --- a/module/src/creatures/cycle/mod.rs +++ b/module/src/creatures/cycle/mod.rs @@ -496,8 +496,6 @@ pub(crate) struct Fighter { pub victim: u64, } -/// Threat's surface. The authority is `crate::threat`: who counts as a valid source, how ties break, -/// and when a taunt lock is still live all stay there — the cycle only composes them. pub(crate) trait ThreatSink { /// Every engaged creature this firing covers, with the unit it is fighting now. fn fighters(&self, scope: &TickScope) -> Vec; diff --git a/module/src/creatures/eventai/engine.rs b/module/src/creatures/eventai/engine.rs index b791d794e..5c833aef3 100644 --- a/module/src/creatures/eventai/engine.rs +++ b/module/src/creatures/eventai/engine.rs @@ -778,11 +778,6 @@ fn rule_uses_linked_random(rule: &EventAiRule) -> bool { CreatureInstruction::RandomPhase(_) | CreatureInstruction::RandomPhaseRange(_) => { true } - // The linked lane seeds the Relay Run's saved random state, and the relay's own - // steps (multi-line talk, random emote, dynamic move) consume it. This predicate - // cannot see inside a Relay Definition, so starting one always draws a real roll: - // the deterministic fallback is stable across a long window and would otherwise - // freeze a whole authored sequence to one outcome for the session. CreatureInstruction::StartRelay(_) => true, CreatureInstruction::FleeForAssist | CreatureInstruction::CallForHelp(_) diff --git a/module/src/creatures/pet.rs b/module/src/creatures/pet.rs index a4d3671df..4bf60367e 100644 --- a/module/src/creatures/pet.rs +++ b/module/src/creatures/pet.rs @@ -34,13 +34,6 @@ use crate::{ use super::spawn::{build_creature_entity, CreatureSpawn}; -// ── Pet command bar (CMSG_PET_ACTION → stay/follow/attack/dismiss + passive/defensive/aggressive) ── -/// The player's pet-bar command/react state, in a MODULE-ONLY table (the gateway never reads it — it -/// only relays the resulting entity/melee changes, so no gateway binding is needed). Keyed by -/// `owner_guid` (one pet per owner). An ABSENT row IS the vanilla default (Follow + Defensive), which -/// is byte-for-byte the derived behavior pets had before the bar — so every existing pet is unchanged -/// until the player presses a bar button. Ephemeral: cleared on despawn/logout with the pet (vanilla -/// pets persist their state; that's a durable-per-character follow-up, not v1). [entity] #[table(accessor = game_pet_command)] pub struct PetCommand { #[primary_key] @@ -49,7 +42,7 @@ pub struct PetCommand { pub command: u8, /// REACT_* — governs auto-engage. pub react: u8, - /// the ATTACK command's target (0 otherwise). + pub command_target: u64, } diff --git a/module/src/creatures/spawn.rs b/module/src/creatures/spawn.rs index 5f3e3aeff..2ded20e65 100644 --- a/module/src/creatures/spawn.rs +++ b/module/src/creatures/spawn.rs @@ -17,9 +17,7 @@ use crate::{game_active_taxi_flight, game_race_info, game_world_entity, Characte #[table( accessor = game_creature_template, public, - // Perf catalog 1.19: `tick::active_cell_radius` used to fold `max(aggro_range)` over EVERY template - // on every 500ms firing. With this btree it asks the only question that can change the answer — - // "does any template override exceed the visibility floor?" — as one index probe. + index(accessor = by_aggro_range, btree(columns = [aggro_range])) )] pub struct CreatureTemplate { @@ -64,7 +62,7 @@ pub struct CreatureTemplate { #[default(0)] pub aggro_range: u32, - // Per-creature melee swing damage (parity #7): cmangos MinMeleeDmg/MaxMeleeDmg, rounded to int. Lets + // Per-creature melee swing damage (parity): cmangos MinMeleeDmg/MaxMeleeDmg, rounded to int. Lets // an L5 Garrick (6-7) hit harder than an L1 wolf (2) instead of every mob sharing the flat 1-3. // `damage_max == 0` ⇒ "not imported" → `swing_range_ctx` falls back to CREATURE_MELEE_MIN/MAX, so // existing rows + the seed chicken stay byte-identical. `#[default(0)]` + end-appended (migration rule). @@ -80,7 +78,7 @@ pub struct CreatureTemplate { #[default(0)] pub armor: u32, - // Loot-family completeness (work-item 210): the creature's PICKPOCKET and SKIN loot-table ids — + // Loot-family completeness : the creature's PICKPOCKET and SKIN loot-table ids , // cmangos `creature_template.PickpocketLootId` / `SkinLootId`, sitting immediately after `LootId` // in the real schema (the importer's `ct::PICKPOCKET_LOOT_ID`/`ct::SKIN_LOOT_ID`, `[V]` — confirm // against your own dump). `pickpocket_loot_id` keys `game_pickpocket_loot` directly by CREATURE @@ -110,20 +108,6 @@ pub struct CreatureTemplate { pub trainer_class: u8, } -/// Beast-family reference data, keyed by the family id that `CreatureTemplate.creature_family` -/// points at (cmangos `creature_template.family`, already imported — see that column's doc -/// comment). Source: `CreatureFamily.dbc` (work-item 214, the 188 pet system's data half). -/// `pet_food_mask` is the cmangos `PetDiet` bitmask (`MEAT 0x1 · FISH 0x2 · CHEESE 0x4 · -/// BREAD 0x8 · MUSHROOM 0x10 · FRUIT 0x20 · RAW_MEAT 0x40 · RAW_FISH 0x80`) — which food item -/// classes satisfy this family's hunger. `pet_talent_type` is `-1` for a non-pet family (most -/// beasts — wolves, boars, bears that are never tameable) and `>= 0` for a tameable Hunter-pet -/// family (the talent tree the tamed pet gets). `category` is the DBC's own grouping id, kept -/// as the raw foreign key (same "store the raw key" convention as `race_info_sql`/`faction_sql` -/// in the importer). NOTE: vanilla `CreatureFamily.dbc` carries NO skill-line column — a pet -/// family's actual spells ride `SkillLineAbility` (work-item 208's `game_skill_ability`), not -/// this table. Consumer: work-item 188 (the pet feeding gate reads `pet_food_mask`, the -/// tameable gate reads `pet_talent_type != -1`) — not wired up here, data-only. No Timestamp → -/// SQL-seedable, importer-owned (clear+reload). [static] #[table(accessor = game_creature_family, public)] pub struct CreatureFamily { #[primary_key] @@ -251,12 +235,12 @@ pub struct NpcText { pub text: String, // slot 0, male (back-compat) } -/// The remaining weighted npc_text slots (work-item 217 — vanilla ships 8 greeting variants; the +/// The remaining weighted npc_text slots, vanilla ships 8 greeting variants; the /// CLIENT does the random weighted pick from `SMSG_NPC_TEXT_UPDATE`'s 8-slot array, no server RNG, /// see `gateway::codec::build_npc_text_update`). A SEPARATE table from `NpcText`, NOT an end-append /// of it: SpacetimeDB 2.5's `#[table]` macro cannot default a `String` column — `#[default(String::new())]` /// fails to compile (`error[E0493]`: `String`'s `Drop` can't run inside the macro's compile-time -/// type-check, which is a plain `const { .. }` block — this is a hard Rust limitation, not a repo +/// type-check, which is a plain `const {.. }` block, this is a hard Rust limitation, not a repo /// convention, so it applies to ANY end-appended `String` column, verified in this pass). A brand /// NEW table sidesteps it entirely (zero existing rows ⇒ nothing to backfill ⇒ no column needs a /// default at all), and this is the SAME one-row-plus-child-rows shape already used everywhere else @@ -284,7 +268,7 @@ pub struct NpcTextSlot { pub probability: f32, } -/// Per-creature gossip MENU OPTION (work-item 217): a clickable line in `SMSG_GOSSIP_MESSAGE` +/// Per-creature gossip MENU OPTION : a clickable line in `SMSG_GOSSIP_MESSAGE` /// (browse-goods / make-home / "Train me" / plain gossip text / submenu link), imported from cmangos /// `gossip_menu_option`. Keyed by creature template `entry` (NOT the cmangos `gossip_menu_option.menu_id` /// — the importer collapses the same menu→entry indirection `GossipMenu` already collapses for the @@ -301,7 +285,7 @@ pub struct NpcTextSlot { /// `lyracore_shared::constants::gossip_condition`; an option the importer can't classify gets `cond_type = 0` /// (fail-open, always shown) rather than silently hidden, and is logged at import time. /// -/// A NEW table (work-item 217) → auto-migrates with no `-c`. No Timestamp → SQL-seedable, +/// A NEW table → auto-migrates with no `-c`. No Timestamp → SQL-seedable, /// importer-owned (clear+reload each ETL run, like its sibling `GossipMenu`). [static] #[table(accessor = game_gossip_option, public, index(accessor = by_entry, btree(columns = [entry])))] pub struct GossipOption { @@ -318,12 +302,6 @@ pub struct GossipOption { pub cond_value2: u32, // reserved (unused by the current minimal condition set) } -/// Which spell a caster-type creature nukes/debuffs with while engaged, keyed by creature entry (one -/// cast spell per entry for this slice). The CAST pass in `tick_creatures` fires it at the creature's -/// current melee target. The cast cadence is the SPELL'S OWN GCD — `resolve_cast_at` rejects (returns -/// `Err`, ignored) while on cooldown — so no separate cooldown column is needed. Hand-authored -/// reference data, public + read-only, no Timestamp → SQL-seedable. A creature with NO row never casts -/// (baseline-safe — it just melees/chases as before). [static] #[table(accessor = game_creature_cast, public)] pub struct CreatureCast { #[primary_key] @@ -381,44 +359,6 @@ pub const MOVEMENT_IDLE: u8 = 0; pub const MOVEMENT_RANDOM: u8 = 1; pub const MOVEMENT_WAYPOINT: u8 = 2; -/// The persistent spawn record for a creature — the source of truth that survives the creature's -/// death. The live `game_world_entity` row is deleted on death; this row holds the data to -/// re-create it once `respawn_at` elapses. While the creature is alive, `respawn_at` is -/// ignored. [static] -/// The **not-armed sentinel** for `respawn_at` / `despawn_at`. -/// -/// Both columns are "ignored" in most states — `respawn_at` while the creature is alive, -/// `despawn_at` while it is not a corpse — and they used to be stamped with `ctx.timestamp` at -/// creation, i.e. a value permanently in the PAST. That made the due predicate -/// (`respawn_at <= now`) true for every row in the table, so `pass_respawn` and `pass_decay` could -/// only ever find their handful of genuinely-due rows by scanning all of them and re-testing the -/// state that actually mattered (is the entity gone / is it dead). -/// -/// Measured: three such passes visited 5,735 rows each, every sense tick, with **nobody online** — -/// 2.2% of the writer at idle, and it scales with SPAWN COUNT, not players, so a full-world import -/// (~10× the spawns) multiplies it. -/// -/// Parking the columns far in the future instead makes the due predicate mean what it says, so a -/// btree range scan (`..=now`) visits only rows that are actually armed — normally none. Same trick -/// as `game_aura`'s `by_next_tick` (`0` sentinel, `1..=now` range). `encounter.rs` already used a -/// far-future `respawn_at` for exactly this reason; this promotes that one-off to the rule. -/// -/// Relative rather than absolute (`now + u32::MAX seconds`, ~136 years) to match that precedent and -/// to stay clear of `Timestamp` overflow — anything past `now` is equally "never" to a `..=now` scan. -/// **One-time migration for `timer_never`.** Every spawn row created before the sentinel existed -/// carries `respawn_at`/`despawn_at` stamped at creation time — permanently in the past — so the -/// range scans in `pass_respawn`/`pass_decay` would still visit all of them and the index would -/// narrow nothing. This disarms the timers on rows that are not actually pending anything: -/// -/// * `respawn_at` is disarmed when the creature is ALIVE (a live entity exists for the guid), and -/// * `despawn_at` is disarmed when it is NOT a corpse (no entity, or an entity that is not dead). -/// -/// Deliberately conservative: a genuinely pending respawn (dead/absent creature with a timer) and a -/// genuinely rotting corpse are both left alone, so running this can never resurrect or vanish -/// anything. Idempotent, and safe to run repeatedly. -/// -/// `limit` caps rows touched per call so a big world can be migrated in chunks rather than one -/// enormous transaction; it returns how many it changed, so an operator can loop until it reports 0. #[cfg(feature = "debug_reducers")] #[reducer] pub fn debug_normalize_spawn_timers(ctx: &ReducerContext, limit: u32) -> Result<(), String> { @@ -703,15 +643,6 @@ pub(crate) fn insert_creature_entity(ctx: &spacetimedb::ReducerContext, mut enti crate::hooks::fire_on_creature_spawn(ctx, &payload); } -/// Build the live `game_world_entity` row for a creature from its spawn record + template. Used by -/// both the initial seed and the respawn pass. `rand` (the caller's `ctx.random()`) drives the -/// level/health roll within the template range (`rolled_creature_stats`); pass a fixed value for a -/// deterministic spawn. `owner_identity` is the server sentinel (ZERO); `unit_bytes_0` is a -/// dummy-but-valid Human/Warrior/Male/Mana (non-rendering for a Unit; the codec rejects race 0). -/// `instance_id` is a REQUIRED param (work-item 190 slice 1) — every slice-1 caller passes 0 -/// (open world); it is NOT read from `CreatureSpawn` this slice (spawn templates stay -/// instance-agnostic — slice 2's dungeon population creates per-instance copies at a call site, -/// not by adding a column here). pub fn build_creature_entity( spawn: &CreatureSpawn, tmpl: &CreatureTemplate, @@ -941,11 +872,9 @@ pub fn build_player_entity( // Threaded from the DURABLE column: the reclaim-escalation ladder must survive a // disconnect/relog, or die-relog-die resets every death to the 30s floor. death_expire_micros: character.death_expire_micros, - // Threaded from the DURABLE column (work-item 190 slice 1, always 0 this slice) so a relog - // rebuilds the entity into the instance it was in, not open world — the `death_expire_micros` - // precedent. + instance_id: character.pending_instance_id, - // GM playtest fields (work-item 223) threaded from the DURABLE carry columns (work-item 289 — + // GM playtest fields threaded from the DURABLE carry columns , // the `death_expire_micros` precedent). They are NOT durable settings: `persist_entity` clears // them on a real logout/disconnect and carries them across a despawn/rebuild WITHIN a session // (`persisted_gm_playtest`), so a login still starts at 1× speed / not-godmode while a diff --git a/module/src/creatures/tick/lifecycle.rs b/module/src/creatures/tick/lifecycle.rs index 45e1c6146..8870ff45c 100644 --- a/module/src/creatures/tick/lifecycle.rs +++ b/module/src/creatures/tick/lifecycle.rs @@ -61,9 +61,9 @@ pub(crate) fn despawn_creature_entity(ctx: &ReducerContext, guid: u64) { /// respawn timer armed to a FUTURE time (so respawn does NOT re-create it the same tick). Reaps the /// corpse's item-loot rows. /// -/// Work-item 230 classification: STAYS GLOBAL — `despawn_at` is a due-time, not a proximity concern; a +/// classification: STAYS GLOBAL, `despawn_at` is a due-time, not a proximity concern; a /// corpse must decay on schedule whether or not a player is anywhere near it. -/// Work-item 229: catch-all firing only, still covering ALL instances (see +/// catch-all firing only, still covering ALL instances (see /// `TickScope::runs_global_passes`). Returns spawn rows scanned. pub(crate) fn pass_decay(ctx: &ReducerContext) -> usize { let now_ts = ctx.timestamp; @@ -93,11 +93,7 @@ pub(crate) fn pass_decay(ctx: &ReducerContext) -> usize { }) .map(|s| s.guid) .collect(); - // Work-item 187 (review finding #2): the roll deadline and the corpse decay are stamped from - // the SAME kill timestamp with the SAME 60s constant, and this pass ticks more often than the - // gc sweep — without resolving due rolls FIRST, decay wins the race and silently discards an - // unresolved roll (no winner, no ROLL_WON, item gone). Sweeping here guarantees every roll - // whose deadline has arrived resolves (absent voters auto-pass) before its rows can be reaped. + if !decaying.is_empty() { crate::loot::sweep_loot_rolls(ctx); } @@ -130,10 +126,7 @@ pub(crate) fn pass_decay(ctx: &ReducerContext) -> usize { { continue; } - // The ONE checklist: entity + spline/motion + the whole loot family (slice 4's - // item rows and work-item 187's eligibility snapshot + resolved rolls + votes, so a decayed - // corpse never orphans them — an UNRESOLVED roll blocked the decay above). This pass's own - // extra is the respawn-timer re-arm below. + crate::creatures::despawn_creature_entity(ctx, guid); if let Some(mut spawn) = spawns.guid().find(guid) { // Honor the per-spawn cmangos respawn timer (respawn_secs, seconds from DEATH) over the @@ -181,9 +174,9 @@ pub(crate) fn pass_decay(ctx: &ReducerContext) -> usize { /// elapsed, from its persistent spawn record + template. Runs AFTER decay (decay arms a future /// `respawn_at`, so a just-decayed creature isn't re-spawned this tick). /// -/// Work-item 230 classification: STAYS GLOBAL — `respawn_at` is a due-time; a spawn point must +/// classification: STAYS GLOBAL, `respawn_at` is a due-time; a spawn point must /// repopulate on schedule whether or not a player is nearby to see it happen. -/// Work-item 229: catch-all firing only, still covering ALL instances. Returns spawn rows scanned. +/// catch-all firing only, still covering ALL instances. Returns spawn rows scanned. pub(crate) fn pass_respawn(ctx: &ReducerContext) -> usize { let now_ts = ctx.timestamp; let entities = ctx.db.game_world_entity(); @@ -239,10 +232,10 @@ pub(crate) fn pass_respawn(ctx: &ReducerContext) -> usize { /// `by_state` index would still have visited every depleted node every tick; the due-time range /// visits only the ones actually due. /// -/// Work-item 230 classification: STAYS GLOBAL — `respawn_at_micros` is a due-time (like creature +/// classification: STAYS GLOBAL, `respawn_at_micros` is a due-time (like creature /// respawn/decay), not proximity; GAMEOBJECTS are also out of this item's creature-ticking scope (see /// the "defer it" note above, pre-dating 230). -/// Work-item 229: catch-all firing only, still covering ALL instances. Returns GO rows scanned. +/// catch-all firing only, still covering ALL instances. Returns GO rows scanned. pub(crate) fn pass_gameobject_respawn(ctx: &ReducerContext) -> usize { let now = ctx.timestamp.to_micros_since_unix_epoch() as u64; let gos = ctx.db.game_gameobject(); diff --git a/module/src/creatures/tick/mod.rs b/module/src/creatures/tick/mod.rs index 77e5f4902..0d2b3afe4 100644 --- a/module/src/creatures/tick/mod.rs +++ b/module/src/creatures/tick/mod.rs @@ -128,7 +128,7 @@ pub struct CreatureSpline { /// column of that index is matched by an equality term, and it skips any index with more than 3 /// columns outright (`MAX_EXACT_INDEX_COLS`); range predicates are never index-served at all /// (`IndexProbe::Range` — "we currently never construct this variant") and an `OR` is evaluated - /// row-by-row. So a `grid_x BETWEEN .. AND grid_y BETWEEN ..` box degrades to a full partition + /// row-by-row. So a `grid_x BETWEEN.. AND grid_y BETWEEN..` box degrades to a full partition /// scan — 1.1 BILLION rows examined on `game_gameobject` in a 445-player measurement. Folding the /// two grid columns into one makes `by_cell` a 3-column all-equality index, which the planner CAN /// serve, and the AOI box becomes 25 point probes instead of a scan. @@ -164,13 +164,13 @@ pub struct CreatureSpline { // Patrol scheduling [server] // =========================================================================================== -/// Drives the creature tick — one row per FIRING SCOPE (work-item 229). The seeded row is the +/// Drives the creature tick, one row per FIRING SCOPE. The seeded row is the /// GLOBAL/CATCH-ALL ticker (`instance_id == GLOBAL_TICK_INSTANCE`); an optional DEDICATED row per /// instance makes that instance tick at its own cadence while the catch-all skips it (`TickScope` in /// ai.rs is the coverage rule — a partition, never an overlap, so a second row DIVIDES the per-firing /// work instead of multiplying it). /// -/// HONEST BOUND (work-item 229): SpacetimeDB serializes every reducer on ONE commit stream — this is +/// HONEST BOUND : SpacetimeDB serializes every reducer on ONE commit stream, this is /// LATENCY SMOOTHING + WORK AVOIDANCE, **NOT parallelism**. Each extra row's firings preempt the /// shared stream (10 instances at 100ms = 100 extra transactions/sec), so tight per-instance /// cadences are a knob to use sparingly, measured via the per-pass rows-visited log below. @@ -197,7 +197,7 @@ pub struct CreatureMoveSchedule { // EXISTING seeded row auto-migrates into the catch-all — every live creature keeps exactly one // ticker, no re-seed needed. This table is NOT gateway-subscribed (no entry in connection.rs's // subscription list or gateway/tests/schema_parity.rs's manifest) → no binding hand-sync needed - // (playbook failure-mode #1, the "No" branch). + // (playbook failure-mode, the "No" branch). #[default(18_446_744_073_709_551_615u64)] pub instance_id: u64, } @@ -215,7 +215,7 @@ pub struct CreatureMoveSchedule { /// 0.5s row. This is mangos's one-loop-with-recheck-timers model on one scheduled reducer — a single /// tick, so no cross-scheduler `spline_id` collision. /// -/// INSTANCE SCOPE (work-item 229 — latency smoothing + work avoidance, NOT parallelism; see the +/// INSTANCE SCOPE, latency smoothing + work avoidance, NOT parallelism; see the /// `CreatureMoveSchedule` doc): every firing resolves a `TickScope` from ITS OWN schedule row. The /// catch-all row covers every instance without a dedicated row; a dedicated row covers exactly its /// instance. Coverage is a PARTITION, so no creature is ever ticked by two rows. @@ -257,10 +257,9 @@ pub fn tick_creatures(ctx: &ReducerContext, schedule: CreatureMoveSchedule) { let outcome = crate::creatures::cycle::run(ctx, tick); if global { - // The 230/233 evidence lines describe the WORLD tick; a dedicated row's numbers would only - // muddy them (its scoped stats land in `log_pass_stats` below, labeled per scope). + // World counters exclude dedicated rows, which report their own scoped pass counts. log_active_cell_stats(ctx, outcome.awake); - log_narrowed_pass_stats(ctx); // work-item 233 done-when evidence (rows-visited drop) + log_narrowed_pass_stats(ctx); } log_pass_stats( ctx, @@ -272,10 +271,10 @@ pub fn tick_creatures(ctx: &ReducerContext, schedule: CreatureMoveSchedule) { } // =========================================================================================== -// Active cells [server] — work-item 230: grid-activation; only cells near players tick +// Active cells [server] ,: grid-activation; only cells near players tick // =========================================================================================== -/// Rough heartbeat period (micros) for the active-cell rows-visited log line — the work-item 230 +/// Rough heartbeat period (micros) for the active-cell rows-visited log line, the /// done-when evidence. NOT every tick (would spam `RUST_LOG=info` at the 0.5s movement cadence); /// roughly once a minute is plenty to eyeball the before/after ratio on a live node. const ACTIVE_CELL_LOG_PERIOD_MICROS: i64 = 60_000_000; @@ -291,12 +290,7 @@ const ACTIVE_CELL_LOG_PERIOD_MICROS: i64 = 60_000_000; /// population this item exists to stop scanning — reading it once per tick is cheap. fn active_cell_radius(ctx: &ReducerContext) -> f32 { let visible = spatial::BOX_HALF_SPAN as f32 * spatial::GRID_CELL_SIZE; - // Perf catalog 1.19: the max-fold over every template used to run on EVERY firing (2+/s, and a - // full cmangos import carries ~4,000 templates) even though the visibility floor dominates it at - // every data set we ship. `by_aggro_range` answers the ONLY question that can change the outcome - // in one indexed probe: is there an override big enough to beat the floor? If not, the floor IS - // the answer (provably — see `aggro_override_cutoff`), so the fold is skipped entirely. When one - // does exist (nothing imports one today) we fall back to the exact original fold. + 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() { @@ -404,7 +398,7 @@ fn is_idle_bot( #[derive(Default)] pub(crate) struct TickSweep { /// Creatures within `active_cell_radius` of at least one covered Character that is not an Idle - /// Bot (work-item 230). + /// Bot. pub(crate) active: std::collections::HashSet, /// Live pets (`owner_guid != 0`), in table order — the cycle's pet-phase candidate list. pub(crate) pets: Vec, @@ -413,7 +407,7 @@ pub(crate) struct TickSweep { pub(crate) in_combat: Vec, } -/// Work-item 230 done-when evidence: log the active-cell rows-visited/total ratio roughly once a +/// done-when evidence: log the active-cell rows-visited/total ratio roughly once a /// minute. `total` (a full non-player-entity count) is deliberately gated behind the SAME rare window /// so the O(N) count itself never reintroduces the per-tick cost this item removes. fn log_active_cell_stats(ctx: &ReducerContext, awake: usize) { @@ -427,12 +421,10 @@ fn log_active_cell_stats(ctx: &ReducerContext, awake: usize) { .iter() .filter(|e| !e.is_player()) .count(); - log::info!( - "tick_creatures active-cell (work-item 230): {awake}/{total} creatures visited this tick" - ); + log::info!("tick_creatures active-cell: {awake}/{total} creatures visited this tick"); } -/// Work-item 233 done-when evidence: log the cast/rout/fear rows-visited drop, in +/// done-when evidence: log the cast/rout/fear rows-visited drop, in /// the SAME rare window `log_active_cell_stats` uses (reusing its throttle — no extra per-tick cost). /// `melee_rows` is the candidate universe BOTH the cycle's cast and rout phases outer-loop (identical /// gate: "currently the attacker in `game_melee_attack`"); `fear_rows` is what the fear phase @@ -453,8 +445,8 @@ fn log_narrowed_pass_stats(ctx: &ReducerContext) { .filter(|a| a.eff_kind == crate::spell::A_CONTROL && a.eff_p0 == crate::spell::M_FEAR) .count(); log::info!( - "tick_creatures narrowed passes (work-item 233): cast/rout visit {melee_rows} melee rows, \ - fear visits {fear_rows} aura rows, vs {total_all} total entities each used to scan" + "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" ); } @@ -468,35 +460,8 @@ fn scope_label(scope: &TickScope) -> String { } } -/// Emit the per-pass rows-visited line for this firing, labeled with the firing's scope, throttled to -/// the SAME once-a-minute window as `log_active_cell_stats` (work-items 230/233 precedent). -/// -/// WHY A SAMPLED LOG LINE AND NOT A `game_tick_stats` TABLE ROW: the counter must not itself become -/// the tax it measures — a table write per firing appends 2 rows/sec (world tick alone; +10/sec per -/// 100ms dedicated row) to the SAME serialized commit stream the honesty addendum warns about, and -/// this repo has no debug-feature-gated tick path to hide it behind (`debug_reducers` gates whole -/// reducers, not branches of a hot scheduled one). The done-when ("per-pass row-visit count does not -/// grow with instance count") needs COMPARATIVE evidence, which a once-a-minute INFO sample answers: -/// grep two samples, before and after arming a dedicated row, and compare per-pass counts. [V] the -/// live readout itself (no node in the sandbox) — runbook in work-item 229, "per-instance-ticks" -/// (archived). -/// -/// COUNTER SEMANTICS (review finding — the two families are NOT comparable to each other): scoped -/// passes count POST-GATE candidates (rows this scope actually considered — these must not grow -/// when another instance is armed/populated), while the `*`-suffixed global passes count what they -/// visited across ALL instances (full table rows for decay and respawn, the harvested candidate -/// list for regen and combat exit) — those grow with world size BY DESIGN and answer 233-style scan -/// questions, not scoping ones. -/// -/// WINDOW: `max(own interval, 500ms)` — an interval-spaced firing lattice always has exactly one -/// point in any half-open window of its own interval's length, so EVERY row logs once a minute; -/// the old fixed 500ms window let a slow row (the runbook's slow-a-row-by-hand pause substitute, -/// e.g. 1000ms) miss the window FOREVER on an unlucky arm-time phase (review finding). -/// -/// SENSE AT THE LOG WINDOW (500ms catch-all): now ∈ [60s·k, 60s·k + 500ms) ⇒ ⌊now/500ms⌋ = 120k -/// ≡ 0 (mod SENSE_EVERY_N_TICKS=8) — phase-independent, so the catch-all's logged line ALWAYS -/// carries the sense-gated counters (regen/decay/…). Dedicated rows can log sense=false lines; -/// the flag is printed, read accordingly. +/// Sample per-pass row visits once per minute without adding diagnostic table writes. +/// Catch-all samples include the sense pass; dedicated samples report whether it ran. fn log_pass_stats( ctx: &ReducerContext, scope_label: &str, @@ -514,13 +479,13 @@ fn log_pass_stats( .collect::>() .join(" "); log::info!( - "tick_creatures pass rows-visited (work-item 229): scope={scope_label} sense={sense} {body} (*=full-table scan, scales with world not instances)" + "tick_creatures pass rows-visited: scope={scope_label} sense={sense} {body} (*=full-table scan, scales with world not instances)" ); } -/// The ONE shared creature move-leg writer (work-item 181): every movement decision (the cycle's +/// The ONE shared creature move-leg writer : every movement decision (the cycle's /// idle and chase legs, flee, fear-flee) funnels its ALREADY-STEPPED landing point through here, so a -/// single ground-snap / anti-desync fix (work-item 174) applies to ALL of them at once. The per-pass +/// single ground-snap / anti-desync fix applies to ALL of them at once. The per-pass /// STEP is computed by the caller BEFORE this call (different math per pass — waypoint segment / chase /// step / walk-home / wander hop / flee dash); this owns only what every pass shares: /// 1. ground-snap the landing point (`snap_z`) — off-slice / unimported areas fall back to the @@ -713,7 +678,7 @@ pub(crate) fn emit_creature_leg( now_ms: u32, set_leg_ends: bool, ) { - // Ground-snap THIS leg's landing point (work-item 174) — one snap now covers every pass. + // Ground-snap THIS leg's landing point, one snap now covers every pass. let nz = crate::terrain::snap_z(ctx, e.map_id, e.instance_id, to.0, to.1, z_fallback); // REFUSE a non-finite leg (see `ai::finite_point`). Writing one makes the creature invisible to // this very tick — its grid cell casts to `i32::MIN`, so no active cell ever contains it again — @@ -727,7 +692,7 @@ pub(crate) fn emit_creature_leg( ); return; } - // ONE WRITER (work-item 181/383): funnel the row build + upsert through `emit_move_spline` — the + // ONE WRITER : funnel the row build + upsert through `emit_move_spline`, the // SAME call the cycle's spline-advance halt and its chase stop // already use, so "one spline writer" is a fact the type system enforces, not doctrine repeated at // each call site. Was: a `game_creature_move_event` INSERT (globally subscribed — so every leg was @@ -886,7 +851,7 @@ pub(crate) fn advance_needs_persist( /// The shared gate ladder every ENGAGED/table-driven phase (cast, threat retarget, chase, rout and /// fear) opens its per-candidate loop with: resolve `guid` to a live CREATURE (no PLAYER bit, not -/// dead) whose instance THIS firing's `scope` covers. `None` collapses each site's `let Some(c) = ... +/// dead) whose instance THIS firing's `scope` covers. `None` collapses each site's `let Some(c) =... /// else { continue }; if c.is_player() || c.dead { continue }; if !scope.covers(c.instance_id) { /// continue }` into one check — every call site still increments its own `visited` counter only on /// `Some`, matching the existing "gate first, then count" order everywhere. diff --git a/module/src/debug/encounter.rs b/module/src/debug/encounter.rs index 2cef89c9e..3d50e35a8 100644 --- a/module/src/debug/encounter.rs +++ b/module/src/debug/encounter.rs @@ -1,4 +1,4 @@ -//! Encounter kernel levers (work-item 228) — operator stand-ins until 227's Deadmines package +//! Encounter kernel levers, operator stand-ins until 227's Deadmines package //! consumes the primitives for real. Each is a thin `?`-wrapper over the `crate::encounter` fn it //! names, so the runbook can exercise every primitive on a live node without an encounter package. diff --git a/module/src/debug/fingerprint.rs b/module/src/debug/fingerprint.rs index 68da8ceca..e83d546ad 100644 --- a/module/src/debug/fingerprint.rs +++ b/module/src/debug/fingerprint.rs @@ -58,12 +58,6 @@ fn fold_table( encoded.hash(hasher); } -/// Fold a CURATED family of tables into one hash+count+comma-joined table list in one shot — the -/// local macro `debug_catalogue_fingerprint`'s per-family blocks expand to, replacing what used to be -/// a hand-written `fold_table(&mut h, &mut n, "name", ctx.db.name().iter())` call PER TABLE (6 lines -/// each, rustfmt-exploded on the `impl Iterator` arg) with one line naming the members. `$ctx.db -/// .$table().iter()` reproduces exactly what those hand-written calls did; the returned `tables` -/// string is the same comma-joined list the deleted code built by hand, in the same member order. macro_rules! fold_family { ($ctx:expr, $h:expr, $n:expr; $($table:ident),+ $(,)?) => {{ $( fold_table(&mut $h, &mut $n, stringify!($table), $ctx.db.$table().iter()); )+ diff --git a/module/src/debug/instance.rs b/module/src/debug/instance.rs index bfe495870..8c34cd0a0 100644 --- a/module/src/debug/instance.rs +++ b/module/src/debug/instance.rs @@ -1,4 +1,4 @@ -//! Dungeon-instancing fixture + operator levers (work-item 190 slices 2+3), plus the nav-probe and +//! Dungeon-instancing fixture + operator levers, plus the nav-probe and //! one-time-backfill levers that accreted next to them (no section of their own). use spacetimedb::{log, reducer, ReducerContext, Table}; @@ -29,11 +29,6 @@ use crate::{ /// "sees only its own population" should therefore be made on a FRESH login after the switch /// (login rebuilds into `pending_instance_id` with a fresh created set + initial-apply sweep). /// The real dungeon flow (areatrigger portal) is cross-map and has no such caveat. -/// -/// COST NOTE: the population copy covers EVERY spawn template on the character's map — on the -/// bare seeded dev node that's a handful of creatures; on a node with the full Elwynn/Westfall -/// import it's the whole ~2k-spawn map-0 roster per instance. Fixture-scale testing belongs on -/// the seeded node (or accept the copy cost knowingly). #[reducer] pub fn debug_create_fixture_instance( ctx: &ReducerContext, @@ -147,7 +142,7 @@ pub fn debug_nav_probe(ctx: &ReducerContext, map: u32, x: f32, y: f32) { ); } -/// Toggle nav-grid consumption (`game_config.nav_enabled`, work-item 243) — upserts row 0 like +/// Toggle nav-grid consumption (`game_config.nav_enabled`), upserts row 0 like /// `debug_set_xp_rate`. OFF = pre-243 straight-line movement + wall-blind aggro/casts. #[reducer] pub fn debug_set_nav_enabled(ctx: &ReducerContext, enabled: bool) -> Result<(), String> { @@ -311,27 +306,6 @@ pub fn debug_vmap_area_info(ctx: &ReducerContext, map: u32, x: f32, y: f32, z: f ); } -/// Done-when: server-side proof that a probe (and a creature spawn) stand on a model floor -/// instead of the terrain underneath it — the headless substitute for eyeballing a bridge/WMO -/// interior deck (e.g. Deadmines) in the live client. Same synthetic-rig shape as -/// `debug_assert_blink_clamp`/`debug_assert_chase_stops_at_column` (the exact -/// `lyracore_shared::vmap::encode` codec, a fixed ORIGIN in an unimported-terrain patch of the map -/// so `terrain::ground_z` is known-`None` there and can't accidentally agree with the synthetic -/// floor by coincidence), but the rig is a horizontal quad — a floor, not a vertical wall — -/// elevated `FLOOR_Z` above a PROBE that lands a hair (`PROBE_UNDERSHOOT_YD`, comfortably inside -/// `floor_z`'s upward search margin) below it, standing in for the ordinary case: imported spawn -/// data or a computed movement destination that's already close to the real surface but not -/// exact, because whatever produced it (client extraction, terrain interpolation) doesn't know -/// about the bridge deck. `floor_z`'s contract is "topmost floor AT OR BELOW the probe" — it is -/// not a levitation search, so this probe is deliberately realistic rather than far below the -/// deck. Asserts, in order: (1) `vmap::floor_z` finds it from a probe just under it, (2) -/// `terrain::snap_z` picks the floor over the (missing) terrain fallback — the `max(terrain, -/// floor)` integration the issue specifies, and (3) a creature spawned there through the REAL -/// `insert_creature_entity` chokepoint (spawn.z left at that same undershoot, exactly like an -/// imported spawn row with no floor awareness) lands on the floor, not at its spawn.z. Cleans up -/// the synthetic vmap rows, the spawn/entity rows and the `vmap_enabled` flag unconditionally -/// (cleanup runs before any assert can fail out), so it never leaves the database in a different -/// state than it found it. #[reducer] pub fn debug_assert_floor_snap( ctx: &ReducerContext, @@ -1018,17 +992,6 @@ pub fn debug_bench_los( ); } -/// Perf-risk companion to `debug_bench_los`: `nav_step`'s collision gate (`vmap::collision_ray`) -/// runs on every COMMITTED movement step (chase/return/wander/flee/pet-follow — the 500ms tick), -/// not the 4s sense tick the LoS benchmark measured, so it pays the exact-ray cost 8x more often per mover. Same -/// shape as `debug_bench_los` — one probe per real creature per compass direction, `radius` yd out -/// — but calls `nav::nav_step` (the actual committed-step choke point) instead of a raw LoS ray, so -/// the query volume this produces is directly comparable against `debug_bench_los`'s numbers on the -/// SAME box: if this reducer's wall time isn't roughly `directions`× cheaper per creature than -/// `debug_bench_los(exact=true)`, the per-tick multiplication the review flagged is real and -/// needs the "cheap PK existence check" fallback the issue text proposed. `gated` counts how many -/// probes the ray actually truncated (checksum, not just speed, so two runs can be diffed for -/// correctness too). #[reducer] #[allow(clippy::too_many_arguments)] // A reducer's arguments are its call signature. pub fn debug_bench_collision_gate( @@ -1078,7 +1041,7 @@ pub fn debug_bench_collision_gate( ); } -/// Backfill the imported default action bar (work-items 110/212) onto an EXISTING character — +/// Backfill the imported default action bar onto an EXISTING character , /// chars created before the `game_createinfo_action` import have no `game_player_action` rows and /// fall back to the gateway's known-spells synth (which slots passives and misses the stance /// pages). Idempotent per button (the grant skips occupied ones). Takes effect next login. diff --git a/module/src/debug/mod.rs b/module/src/debug/mod.rs index 610676ff8..db2d22fc7 100644 --- a/module/src/debug/mod.rs +++ b/module/src/debug/mod.rs @@ -1,7 +1,5 @@ -//! Layer 1 — server-side **test-only** reducers that pilot the world for the client-automation -//! harness. Each puts the world in an exact precondition -//! (teleport / set HP / spawn-at-feet / force-cast / set level / clear creatures) so most ⭐ tests -//! lose their blind 3D-navigation step and become deterministic. +//! Reducers that stage deterministic scenarios for client automation and operator diagnostics. +//! Teleport, health, spawn, cast, and inventory operations accept explicit Character guids. //! //! # Feature gate //! `lib.rs` declares `mod debug;` behind `#[cfg(feature = "debug_reducers")]`. A default build @@ -15,7 +13,7 @@ //! `docs/danger-zones.md` is authoritative on that posture. The post-publish repair pass lives in //! the ungated `operations` module. //! -//! # CRITICAL identity caveat (docs §6 / HANDOFF §10.2) +//! # Caller identity //! A `spacetime call` runs as the **CLI identity**, which is NOT a player's bound identity. So every //! reducer here takes an **explicit `character_guid`/`guid`** and operates on that row directly — it //! MUST NOT use `entity_by_owner(ctx, ctx.sender())` (that only resolves the gateway's per-player @@ -192,12 +190,6 @@ pub fn debug_apply_damage( /// (`health = 0` + `dead = true`, then the client shows Release Spirit — vanilla 1.12 has no death /// opcode, `UNIT_FIELD_HEALTH == 0` is the sole signal); a CREATURE becomes a corpse (`dead = true`, /// disengaged, decay timer armed, loot rolled onto the corpse). -/// -/// Routes through the SAME shared kill path combat uses (`combat::kill_player` / `combat::kill_creature`) -/// so a debug kill is byte-identical to a real killing blow, including channel teardown and combat- -/// deadline zeroing — not a re-expression of the swing-loop kill logic (this doc used to sit, -/// misattached, above `debug_apply_damage`, and claimed "the combat module has no reusable kill fn", -/// which the body below has not been true of since `combat::kill_player`/`kill_creature` landed). #[reducer] pub fn debug_set_health(ctx: &ReducerContext, guid: u64, health: u32) -> Result<(), String> { let entities = ctx.db.game_world_entity(); @@ -449,10 +441,6 @@ fn resolve_debug_cast_at( ) } -/// Like `debug_force_cast` but directs the cast at an explicit `target_guid` instead of self — -/// used to test targeted interrupts (e.g. Kick: caster A casts Kick on target B who is mid-cast). -/// For interrupt testing the `lockout_ms` value is read from `game_spell.duration_ms` for the -/// interrupting spell; change that value to verify the lockout window changes with zero engine change. #[reducer] pub fn debug_force_cast_at( ctx: &ReducerContext, @@ -467,7 +455,7 @@ pub fn debug_force_cast_at( /// (`stats::max_health_for`/`max_power_for`, the SAME helpers `player_login` uses — not reimplemented). /// Health/power are refilled to the new max for the test. Also recomputes the five base attributes so /// the character sheet stays consistent with the new level. Thin wrapper over the shared -/// `stats::set_character_level` core (work-item 223 factored it out so `gm::gm_command`'s `.level` +/// `stats::set_character_level` core factored it out so `gm::gm_command`'s `.level` /// reuses the SAME recompute — see that fn's doc). #[reducer] pub fn debug_set_level( @@ -680,7 +668,7 @@ pub fn debug_sell_item( /// Enter AreaTrigger `trigger_id` as `character_guid` — drives `enter_areatrigger`'s shared core /// (`apply_enter_areatrigger`) by explicit guid for the harness: credits any "explore" quest tied to -/// the trigger, AND (work-item 225) routes through a cross-map teleport if `trigger_id` is an imported +/// the trigger, AND routes through a cross-map teleport if `trigger_id` is an imported /// `game_areatrigger_teleport` row — the same live runbook this debug twin drives for 224 exercises the /// Deadmines portal end-to-end without a physical CMSG_AREATRIGGER from the client. #[reducer] @@ -800,13 +788,13 @@ pub fn debug_spawn_gameobject( gather_skill_line, respawn_secs, gather_gray, - lock_id: 0, // work-item 211: the debug spawn lever doesn't carry a lockId this slice - size: 0.0, // no dump size — the gateway renders this at 1.0 + lock_id: 0, + size: 0.0, // no dump size, the gateway renders this at 1.0 }); } let guid = (0xF110u64 << 48) | template_entry as u64; ctx.db.game_gameobject().guid().delete(guid); - // Clear any prior Pick-Lock unlock (work-item 119) for this DERIVED guid so a re-spawn of a locked + // Clear any prior Pick-Lock unlock for this DERIVED guid so a re-spawn of a locked // template is deterministically LOCKED again (the verify's negative case relies on this). ctx.db.game_gameobject_unlocked().go_guid().delete(guid); let go = ctx @@ -949,8 +937,8 @@ pub fn debug_setup_gather_pool( gather_skill_line, respawn_secs, gather_gray: 0, - lock_id: 0, // work-item 211: pool test templates don't carry a lockId this slice - size: 0.0, // no dump size — the gateway renders this at 1.0 + lock_id: 0, + size: 0.0, // no dump size, the gateway renders this at 1.0 }); } member_tbl.insert(crate::gameobject::GameObjectPoolMember { @@ -977,7 +965,7 @@ pub fn debug_setup_gather_pool( /// Force a pool RE-ROLL NOW for a deterministic no-wait verify (the ~4s sense tick would do the same on /// the armed timer). Deactivates `gathered_guid` and activates a different inactive weighted-chosen /// point — the active count stays `max_active`. `gathered_guid` is the live guid of the point to roll; -/// resolve it from the SQL `SELECT guid FROM game_gameobject WHERE ...` (it's < 2^53 only for small +/// resolve it from the SQL `SELECT guid FROM game_gameobject WHERE...` (it's < 2^53 only for small /// point_ids — pass it after a `--` if negative-looking, though GO guids are large/unsigned). #[reducer] pub fn debug_force_pool_reroll( @@ -1116,7 +1104,7 @@ pub fn debug_begin_cast( } /// Cast a GROUND-TARGETED spell at an explicit world point by caster guid — server-side proof of the 118 -/// phase-2 dest plumbing (the CLI owns no entity, so it can't drive the real `cast_spell_at`). Drives the +/// dest plumbing (the CLI owns no entity, so it can't drive the real `cast_spell_at`). Drives the /// full `begin_cast(Some(dest))` path, so a TIMED ground spell also exercises the PendingCast dest carry. #[reducer] pub fn debug_cast_spell_at( @@ -1242,10 +1230,6 @@ pub fn debug_log_vendor_discount( // `#[reducer]`: SpacetimeDB reducers take their arguments FLAT off the wire (`spacetime call`), so a parameter struct is not available. #[allow(clippy::too_many_arguments)] -/// Drive the anti-cheat movement scorer (255) by explicit guid + two positions + move times — the CLI -/// owns no entity so it can't send a real heartbeat, this proves the live `score_and_log_movement` path -/// inserts a `game_movement_violation` row on an anomalous delta (and none on a legit one). Server-side -/// verification only (never in the live movement flow). #[reducer] pub fn debug_score_movement( ctx: &ReducerContext, @@ -1396,13 +1380,6 @@ pub fn debug_accept_quest( /// kill credit for `victim_entry` and an item grant). The wire test asserts the client still /// receives SMSG_QUESTUPDATE_ADD_KILL and SMSG_ITEM_PUSH_RESULT out of this fat transaction — the /// delivery guarantee the coordinator relay migration exists to provide. -/// -/// `junk_rows` used to pad the transaction with `game_creature_move_event` inserts (rows no -/// subscription matched, reaped by the old 1s event TTL) as ballast for the fat-transaction/AOI-churn -/// relay-drop class. That table has had no gateway subscriber since perf 2.3 (`gc.rs`), so those -/// inserts stopped being ballast and became a pure leak — deleted here, not rerouted. Kept as -/// an accepted-but-ignored arg so an existing caller passing a nonzero value doesn't hit an arity -/// error; it just no longer does anything. #[reducer] pub fn debug_stress_relay( ctx: &ReducerContext, @@ -1414,7 +1391,7 @@ pub fn debug_stress_relay( if junk_rows > 0 { spacetimedb::log::warn!( "debug_stress_relay: ignoring junk_rows={junk_rows} — the game_creature_move_event \ - ballast it padded was retired as a dead-table leak (#357); pass 0" + ballast is no longer supported; pass 0" ); } crate::quest::on_creature_killed(ctx, character_guid, victim_entry); @@ -1435,7 +1412,7 @@ pub fn debug_grant_quest( crate::actor::stage_quest(ctx, character_guid, quest_entry) } -/// Force-expire `character_guid`'s `quest_entry` (work-item 194) — the harness twin of the timed-quest +/// Force-expire `character_guid`'s `quest_entry`, the harness twin of the timed-quest /// tick (`quest::quest_timer_pass`), for the runbook: verify `SMSG_QUESTUPDATE_FAILEDTIMER` fires on /// the wire and the quest is re-acceptable, without waiting out `limit_time` seconds live. #[reducer] @@ -1450,7 +1427,7 @@ pub fn debug_expire_quest( crate::quest::debug_force_expire(ctx, character_guid, quest_entry) } -/// Share `quest_entry` from `character_guid` to its party (work-item 194) — drives +/// Share `quest_entry` from `character_guid` to its party, drives /// `push_quest_to_party` by explicit guid (the CLI identity owns no entity to resolve via /// `ctx.sender()`). #[reducer] @@ -1534,10 +1511,6 @@ pub fn debug_kill_creature( } } -/// Kill the nearest LIVE creature of `creature_entry` to `killer_guid`, crediting the killer — same shared -/// killing-blow path as [`debug_kill_creature`] but selects the target SERVER-SIDE by entry, so a test can -/// advance a kill quest with only small args (the creature's own guid is a u64 > 2^53 the CLI `spacetime -/// call` mangles). Used to verify kill-feedback and the live quest-log update end to end. #[reducer] pub fn debug_kill_nearest( ctx: &ReducerContext, @@ -1857,18 +1830,15 @@ pub fn debug_reset_talents( crate::talent::do_reset_talents(ctx, character_guid, trainer_guid).map(|_| ()) } -/// Floor for a dedicated per-instance tick interval (work-item 229's honesty addendum: every firing +/// Floor for a dedicated per-instance tick interval's honesty addendum: every firing /// is a transaction on the ONE serialized commit stream — 10 instances at 100ms is already 100 /// extra transactions/sec preempting player actions; anything tighter than 50ms is a foot-gun with /// no gameplay payoff at 1.12 animation cadences). const INSTANCE_TICK_MS_FLOOR: u64 = 50; -/// Ceiling for `debug_arm_instance_tick` (229 review): 10 minutes — far beyond any sane cadence -/// (the pause substitute the runbook suggests is seconds, not minutes), and small enough that -/// `tick_ms * 1000` can never overflow `i64` into a NEGATIVE interval (a negative scheduled-table -/// interval risks continuous refiring — the exact commit-stream flood the floor exists to prevent). + const INSTANCE_TICK_MS_CEIL: u64 = 600_000; -/// Arm (or re-arm) a DEDICATED creature-tick row for `instance_id` at `tick_ms` (work-item 229): that +/// Arm (or re-arm) a DEDICATED creature-tick row for `instance_id` at `tick_ms` : that /// instance's creature passes then fire on THIS row at its own cadence, and the catch-all row skips /// the instance (coverage is a partition — see `TickScope` in creatures/ai.rs). The global due-time /// passes (decay/respawn/regen/combat-drop) STAY on the catch-all row for all instances, so this knob @@ -1892,7 +1862,7 @@ pub fn debug_arm_instance_tick( ); } if tick_ms < INSTANCE_TICK_MS_FLOOR { - return Err(format!("tick_ms {tick_ms} below the {INSTANCE_TICK_MS_FLOOR}ms floor (serialized commit-stream tax — see work-item 229)")); + return Err(format!("tick_ms {tick_ms} below the {INSTANCE_TICK_MS_FLOOR}ms floor (serialized commit-stream tax)")); } if tick_ms > INSTANCE_TICK_MS_CEIL { return Err(format!("tick_ms {tick_ms} above the {INSTANCE_TICK_MS_CEIL}ms ceiling (overflow guard — see the const's doc)")); @@ -1915,7 +1885,7 @@ pub fn debug_arm_instance_tick( Ok(()) } -/// Remove the dedicated creature-tick row for `instance_id` (work-item 229) — coverage of that +/// Remove the dedicated creature-tick row for `instance_id`, coverage of that /// instance returns to the catch-all row on its next firing (the `TickScope` rebuild is per firing), /// so its creatures keep ticking at the global 0.5s cadence; nothing is ever stranded. This is also /// the future instance-reap's tick-row cleanup (190 slice 2). @@ -1982,7 +1952,7 @@ pub fn debug_verify_combat_regen(ctx: &ReducerContext, character_guid: u64) -> R } /// Cast `spell_id` as `character_guid` THROUGH the spellbook gate (`knows_spell`) — the by-guid mirror of -/// the sender-bound `cast_spell`, so a test can verify the learnable-spell gate (rank 27 #1): the cast is +/// the sender-bound `cast_spell`, so a test can verify the learnable-spell gate (rank 27): the cast is /// rejected until the spell is in the baseline kit OR learned (e.g. via an ability talent), then accepted. /// Unlike `debug_cast_at` (which drives `resolve_cast_at` and BYPASSES the gate), this exercises the gate. #[reducer] @@ -2050,7 +2020,7 @@ pub fn debug_seed_scenario_fixtures(ctx: &ReducerContext) { // row_count: quest 50900 (1) + how many of the 3 scenario NPC templates (questgiver 51003, // vendor 51004, weapon master 51005) are present after this call — an anchor-row proxy for the // fixture's full spread across quest/text/objective/reward/vendor/trainer tables, not an exact - // total (work-item 216 provenance stamp). + // total (provenance stamp). let quest = ctx .db .game_quest_template() diff --git a/module/src/encounter.rs b/module/src/encounter.rs index 67e544fb0..54f1ceda0 100644 --- a/module/src/encounter.rs +++ b/module/src/encounter.rs @@ -1,4 +1,4 @@ -//! Encounter kernel (work-item 228) — the mangos `InstanceScript` analogue as a PACKAGE-SUPPORT +//! Encounter kernel, the mangos `InstanceScript` analogue as a PACKAGE-SUPPORT //! layer. Ships ZERO encounter content: dungeons' choreography lives in drop-in packages //! (`packages/dungeons/`, first consumer: 227 Deadmines), which consume three things from here: //! @@ -284,10 +284,6 @@ pub struct EncounterSpawn { pub guid: u64, } -/// Client-visible virtual-item display slots for a creature. [`equip_swap`] resolves authored item -/// entries through `game_item_template` before writing this projection. The gateway relays the three -/// display fields through its crash-safe partial VALUES path and replays them after a peer CREATE. -/// Swept per instance ([`sweep_encounter_state`]) and per tracked despawn. [entity] #[table(accessor = game_encounter_equip, public, index(accessor = by_instance, btree(columns = [instance_id])))] pub struct EncounterEquip { #[primary_key] @@ -348,11 +344,6 @@ pub fn wave_guid(entry: u32, next_low: u64) -> u64 { (HIGHGUID_UNIT << 48) | ((entry as u64) << 24) | (next_low & 0x00FF_FFFF) } -/// The creature ENTRY carried in bits 24..47 of every unit guid this codebase mints (importer, -/// seed, debug, wave, instance-population — all share the layout above). Lets cleanup code -/// identify a guid's creature even after the entity row is gone (dead + decayed/reaped), which an -/// entity-table lookup cannot (227 review: the Smite equip reset silently no-op'd on exactly the -/// corpse-decayed case it existed for). Pure. pub fn entry_of_unit_guid(guid: u64) -> u32 { ((guid >> 24) & 0x00FF_FFFF) as u32 } @@ -364,7 +355,7 @@ pub fn wave_offset(index: usize) -> (f32, f32) { } /// `[V]` The `game_gameobject.state` value that renders a DOOR/BUTTON as OPEN on the 5875 client. -/// The in-repo convention (gameobject.rs `toggle_state`'s test labels, work-item 211) is +/// The in-repo convention (gameobject.rs `toggle_state`'s test labels) is /// 0 = closed/ready, 1 = open/used, and the gateway relays the raw value /// (`set_gameobject_state(go.state)`); no live client in this sandbox to confirm the rendering — /// if a live check shows doors inverted, flip THIS const (one place). @@ -416,10 +407,6 @@ pub fn get_encounter_state(ctx: &ReducerContext, instance_id: u64, encounter_id: .unwrap_or(ENCOUNTER_NOT_STARTED) } -/// Set the state, preserving `payload`. Upserts. Packages own the transition semantics (the -/// kernel enforces none — mangos likewise lets a script move Failed→InProgress on a retry). -/// REFUSES a reserved (bit-31) encounter_id: writing one would silently corrupt an hp-fired -/// ratchet row (`hp_fired_key(entry)` shares the table) — review finding; refuse, never corrupt. pub fn set_encounter_state( ctx: &ReducerContext, instance_id: u64, @@ -466,7 +453,7 @@ pub fn set_encounter_data( // =========================================================================================== /// Register "fire `on_hp_threshold` when a creature of `entry` reaches `pct`% or below" — the -/// package-side half of the hook (the `game_hook!(on_hp_threshold, ...)` handler is the other). +/// package-side half of the hook (the `game_hook!(on_hp_threshold,...)` handler is the other). /// Idempotent (check-before-insert, the `learn_spell` grant idiom): packages typically call this /// from their seed/fixture reducer or an `on_creature_spawn` handler, either of which may run /// repeatedly. `pct` must be 1..=99 (0 is the death hook's job; 100 would fire on any scratch). @@ -475,8 +462,6 @@ pub fn watch_hp_threshold(ctx: &ReducerContext, entry: u32, pct: u8) -> Result<( return Err(format!("hp watch pct must be 1..=99, got {pct}")); } if entry & RESERVED_ENCOUNTER_ID_BIT != 0 { - // hp_fired_key(entry) FOLDS bit 31 — two entries >= 2^31 would share one ratchet row - // (review finding). No vanilla entry is remotely close; refuse garbage loudly. return Err(format!( "hp watch entry {entry:#x} has bit 31 set (reserved-key fold)" )); @@ -566,19 +551,6 @@ fn probe_hp_thresholds(ctx: &ReducerContext, target_guid: u64) { } } -// =========================================================================================== -// Choreography primitives -// =========================================================================================== - -/// Open every DOOR/BUTTON gameobject of `go_entry` (Rhahk'Zor's door on his death, the iron door -/// on the cannon blast): set `state` to [`DOOR_OPEN_STATE`] the way a player `use` does (same -/// row update — the gateway's `on_go_update` relay re-emits the CREATE_OBJECT, work-item 211), -/// but SET-OPEN rather than toggle so a re-fired death hook can never swing the door shut again. -/// Idempotent. `instance_id` filters to THAT instance's per-instance GO copies (190 slice 2 landed -/// the `game_gameobject.instance_id` column — the splice this doc used to defer): a boss death in -/// instance 7 opens instance 7's door and nobody else's; `instance_id` 0 targets the open-world -/// (static) rows. Errs when no such GO exists in that instance or its template isn't a DOOR/BUTTON -/// (a chest's `state` means "looted" — refusing the type keeps this verb from corrupting loot state). pub fn open_door(ctx: &ReducerContext, go_entry: u32, instance_id: u64) -> Result { let tmpl = ctx .db @@ -595,8 +567,7 @@ pub fn open_door(ctx: &ReducerContext, go_entry: u32, instance_id: u64) -> Resul )); } let gos = ctx.db.game_gameobject(); - // ONE pass collects both the flip set and existence (review nit: was two full iters). - // 190 slice 2: the instance-equality filter this loop's splice comment documented — landed. + let mut any_of_entry = false; let mut targets: Vec = Vec::new(); for g in gos.iter() { @@ -624,18 +595,6 @@ pub fn open_door(ctx: &ReducerContext, go_entry: u32, instance_id: u64) -> Resul // A wave's full placement (entries, count, anchor position, instance, encounter id). #[allow(clippy::too_many_arguments)] -/// Spawn a wave of adds (VanCleef's bodyguards) at `(x, y, z)` facing `orientation`, tracked for -/// despawn-on-reset. Each add gets a REAL `game_creature_spawn` row (so the normal corpse-decay -/// pass reaps its body) with `respawn_secs = u32::MAX` (~136 years — decay arms the respawn that -/// far out, so a dead add NEVER respawns; the instance is reaped long before) and a -/// `game_encounter_spawn` tracking row so [`encounter_reset`]/[`sweep_encounter_state`] can tear -/// everything down. Spawns via the shared `build_creature_entity`/`insert_creature_entity` path -/// (so `on_creature_spawn` fires per add) at `instance_id`, spread on a 2 yd grid -/// ([`wave_offset`]). Aggro/leash then ride the normal proximity-aggro + pursuit-timer leash -/// passes — a per-wave leash RADIUS is not representable today (the engine's chase/leash radii are -/// global consts, and the return anchor is the spawn row, i.e. the wave anchor); a custom radius needs a -/// `CreatureSpawn` column (deferred, noted in the work item). Entries missing a template are -/// skipped with a log line. Returns the spawned guids in `entries` order. pub fn spawn_wave( ctx: &ReducerContext, instance_id: u64, @@ -674,12 +633,7 @@ pub fn spawn_wave( y: y + dy, z, orientation, - // FAR-FUTURE, not `ctx.timestamp` (review MEDIUM): a past-armed respawn_at is - // respawn-ELIGIBLE the instant the entity vanishes by any path that bypasses decay — - // debug_clear_creatures deletes entities and deliberately keeps spawn rows, and - // pass_respawn rebuilds at INSTANCE 0 — resurrecting the wave into the open world. - // A far-future stamp is equally "never matches while alive" and stays safe after any - // entity delete; decay's re-arm (respawn_secs = u32::MAX) keeps it far-future forever. + respawn_at: ctx .timestamp .checked_add(spacetimedb::TimeDuration::from_micros( @@ -832,12 +786,6 @@ pub fn encounter_reset(ctx: &ReducerContext, instance_id: u64, encounter_id: u32 despawn_tracked(ctx, &tracked); } -/// [`encounter_reset`] + [`reset_hp_fired`] for the named boss entries in ONE call — the verb a -/// wipe handler almost always wants (review finding: a package that calls the bare reset but -/// forgets the ratchets gets a re-pulled boss whose 66/33 events never fire, with no error -/// anywhere; mangos' `Reset()` reconstructs phase state implicitly, so the trap is easy to walk -/// into). Prefer this from 227-style wipe/evade handlers; the split verbs remain for the rare -/// asymmetric reset. pub fn encounter_reset_full( ctx: &ReducerContext, instance_id: u64, @@ -856,11 +804,6 @@ pub fn encounter_reset_full( /// it may already be decay-deleted, which is fine), plus this path's OWN extras: the spawn row (the /// row that would otherwise respawn into instance 0 — see [`EncounterSpawn`]), any equip row, and the /// tracking row itself. -/// -/// This used to carry a private copy of the checklist, and that copy deleted every -/// `game_corpse_loot` row on the guid with NO `!withheld` filter — the invariant, which the other -/// two teardown paths honour. Wave adds are ordinary group-killable creatures and `encounter_reset` is -/// the wipe handler, so a mid-roll wipe hit exactly this delete and silently ate the winner's item. fn despawn_tracked(ctx: &ReducerContext, tracked: &[EncounterSpawn]) { for t in tracked { crate::creatures::despawn_creature_entity(ctx, t.guid); @@ -889,7 +832,7 @@ pub(crate) fn sweep_encounter_state(ctx: &ReducerContext, instance_id: u64) { .collect(); despawn_tracked(ctx, &tracked); let states = ctx.db.game_encounter_state(); - // by_pair prefix scan (instance_id is the leading column) — not a full-table iter (review nit). + let stale: Vec = states .by_pair() .filter(&instance_id) @@ -1000,7 +943,6 @@ mod tests { assert!(is_reserved_encounter_id(hp_fired_key(1))); assert!(is_reserved_encounter_id(hp_fired_key(646))); // Mr. Smite assert_eq!(hp_fired_key(646), 0x8000_0000 | 646); - // ...and no sane package encounter id (below the bit) is ever reserved. assert!(!is_reserved_encounter_id(0)); assert!(!is_reserved_encounter_id(1)); assert!(!is_reserved_encounter_id(0x7FFF_FFFF)); diff --git a/module/src/exploration.rs b/module/src/exploration.rs index 50328f547..8f0120a9b 100644 --- a/module/src/exploration.rs +++ b/module/src/exploration.rs @@ -1,4 +1,4 @@ -//! Exploration / discovery XP (work-item 200; the "Discovered: " toast + map fog have +//! Exploration / discovery XP; the "Discovered: " toast + map fog have //! landed). Entering a subzone for the FIRST time awards discovery XP and sends TWO packets — this is //! retail parity, not a duplicate (see `check_area_exploration`'s doc comment for the //! cited source): `SMSG_EXPLORATION_EXPERIENCE` (the toast + fog-clear, off the diff --git a/module/src/gameobject.rs b/module/src/gameobject.rs index 22c51aaaa..0af90f932 100644 --- a/module/src/gameobject.rs +++ b/module/src/gameobject.rs @@ -1,6 +1,6 @@ //! Gameobjects — the world's interactive props. A spawned `game_gameobject` is relayed to clients as a //! GameObject CREATE_OBJECT (gateway); `use_gameobject` (CMSG_GAMEOBJ_USE) dispatches by template type: -//! a CHEST rolls its real `game_gameobject_loot` table (work-item 210) into the shared +//! a CHEST rolls its real `game_gameobject_loot` table into the shared //! `game_corpse_loot` table KEYED ON THE GO GUID (so the existing corpse loot window + take path serve //! it unchanged) — falling back to the legacy single `data0` drop when the chest has no data-driven //! table (the seed/demo chest) — a GOOBER grants quest credit for a USE_GAMEOBJECT objective, a @@ -14,19 +14,14 @@ use std::collections::HashSet; use spacetimedb::{reducer, table, ReducerContext, Table, TimeDuration, Timestamp}; -use crate::game_gameobject_loot; // CHEST data-driven loot table (work-item 210) +use crate::game_gameobject_loot; use crate::game_player_skill; // GATHER skill-gate reads the gather skill row (accessor trait) use crate::game_world_entity; use crate::nav::game_nav_chunk; // arm_pool's map-fence — neither is wildcard-exported at the crate root use crate::terrain::game_terrain_chunk; -/// The gameobject types this slice handles (cmangos `GAMEOBJECT_TYPE_*`). Deliberate -/// simplification: only these two plus the synthetic GATHER marker. pub mod go_type { - /// Door prop. `use` toggles open/closed (state 0↔1 via [`super::toggle_state`]), byte-identical - /// shape to the BUTTON toggle below. A lock with a real opener must be opened first. - /// autoCloseTime (cmangos data2) is NOT modeled — a toggled-open door stays open - /// until toggled again (no auto-close timer this slice). cmangos type 0 (GAMEOBJECT_TYPE_DOOR). + pub const DOOR: u8 = 0; /// Button/lever prop — cmangos models a lever as a differently-DISPLAYED door; the toggle /// semantics are identical to DOOR (same match arm). cmangos type 1 (GAMEOBJECT_TYPE_BUTTON). @@ -35,7 +30,6 @@ pub mod go_type { /// gate lives entirely in `game_gameobject_quest` (see `quest::validate_giver`); `use_gameobject` /// still does not dispatch on this type SERVER-SIDE (opening the quest window is presentation, not /// a state mutation — the gateway's `CMSG_GAMEOBJ_USE` handler does that dispatch itself, - /// work-item 041). Re-exported from `lyracore_shared::constants::go_type` (not a local literal) so the /// module and gateway copies of this value can never drift. cmangos type 2 /// (GAMEOBJECT_TYPE_QUESTGIVER). pub const QUESTGIVER: u8 = lyracore_shared::constants::go_type::QUESTGIVER; @@ -52,7 +46,7 @@ pub mod go_type { /// and never collides with the CHEST loot-window path. `data0` = item entry granted; `data1` = /// required skill level; `gather_skill_line` = MINING/HERBALISM the use requires. pub const GATHER: u8 = 25; // synthetic; vanilla has no type 25 — our gather marker - // COLLISION NOTE (work-item 211): real cmangos type 25 IS assigned (GAMEOBJECT_TYPE_FISHINGHOLE) — + // COLLISION NOTE : real cmangos type 25 IS assigned (GAMEOBJECT_TYPE_FISHINGHOLE) , // it just happens to collide with this synthetic marker. The importer's TYPE-25 COLLISION GUARD // (`importer/src/main.rs::classify_go_type`) is the ONLY place that decides what gets stored as // module type 25: a real FISHINGHOLE row is dropped from import, never stored here as GATHER. @@ -107,9 +101,6 @@ pub struct GameObjectTemplate { // type. Existing rows default to 0. #[default(0u32)] pub lock_id: u32, - // END-APPENDED. The cmangos `gameobject_template.size` the client renders this prop at - // (OBJECT_FIELD_SCALE_X). 0 = no size stored, which the gateway renders as 1.0. Read by the - // gateway, so it is hand-synced into `game_object_template_type.rs` (unlike `lock_id` above). #[default(0f32)] pub size: f32, } @@ -160,29 +151,14 @@ pub struct GameLock { pub required_skill: u32, // kind 2: the skill VALUE needed; kind 1: unused (Lock.dbc's own 0/garbage) } -/// `game_lock.kind` values (work-item 119): the two opener kinds [`GameLock`] models. +/// `game_lock.kind` values : the two opener kinds [`GameLock`] models. pub(crate) const LOCK_KIND_ITEM: u8 = 1; // property = the key item entry the opener must HOLD pub(crate) const LOCK_KIND_SKILL: u8 = 2; // property = a SkillLine id (Lockpicking 633 / Herbalism 182 / Mining 186) -/// Is a `game_lock` row a REAL opener — a requirement a player can be gated on AND can satisfy -/// (ctx-free, unit-tested)? A SKILL row (kind 2) counts ONLY when it names a real SkillLine -/// (`property != 0`): the importer stores an UNMAPPED `LockType.dbc` id (LOCKTYPE_OPEN / blasting / -/// disarm-trap — "open by hand or a non-lockpick method") as `property 0` (see `importer/src/dbc.rs`'s -/// `LOCKTYPE_TO_SKILL_LINE` — an id outside {1,2,3} resolves to SkillLine 0). Such a slot is NOT -/// lockpicking (Pick Lock can't satisfy it) AND must NOT lock a hand-openable chest — those opened -/// freely pre-119 and still do. An ITEM row (kind 1) always names a key item entry. Any other kind → -/// not a lock this slice models (never gates the use path, never picked). pub(crate) fn is_real_lock_opener(kind: u8, property: u32) -> bool { (kind == LOCK_KIND_SKILL && property != 0) || kind == LOCK_KIND_ITEM } -/// A GameObject whose lock has been PICKED open. The CHEST/GOOBER/DOOR use-gate reads -/// this by GO guid to admit a use that a lock would refuse. Module-only + UNSUBSCRIBED (no gateway -/// binding files, no gateway subscription — the `game_lock` precedent above); `public` + no Timestamp so -/// a verify can inspect it via `spacetime sql`. One row per unlocked GO guid; never re-locked this slice -/// (matches vanilla — a picked chest stays open). A row for a despawned GO is a harmless orphan (a live -/// HIGHGUID_GAMEOBJECT guid is minted per spawn; `debug_spawn_gameobject` clears the row for its DERIVED -/// guid so a re-spawn is deterministically locked again). New table → additive auto-migration. [entity] #[table(accessor = game_gameobject_unlocked, public)] pub struct GameObjectUnlocked { #[primary_key] @@ -196,10 +172,7 @@ pub struct GameObjectUnlocked { public, index(accessor = by_map, btree(columns = [map_id])), index(accessor = by_grid, btree(columns = [map_id, grid_x, grid_y])), - // Perf catalog 1.21: the sense-tick respawn pass used to full-scan this table (~25-30k rows after a - // full-world GO import) for a minutes-scale due check. `respawn_at_micros == 0` is the not-armed - // sentinel on every live row, so a `1..=now` range visits only DUE nodes — the same trick - // `game_aura.by_next_tick` uses. + index(accessor = by_respawn_at, btree(columns = [respawn_at_micros])), index( accessor = by_template_scope, @@ -226,13 +199,13 @@ pub struct GameObject { /// ctx.timestamp + RESPAWN_WINDOW_MICROS; cleared (back to 0) when the respawn pass flips state 0. #[default(0u64)] pub respawn_at_micros: u64, - /// Which instance this GO belongs to (work-item 190 slice 2). 0 = open world — EVERY static + /// Which instance this GO belongs to. 0 = open world, EVERY static /// imported/seeded/pool/debug row stays 0; only the per-instance COPIES `instance::create_instance` /// makes of a dungeon's DOOR/BUTTON/CHEST/GOOBER rows carry a live instance id (GO_COPY_BAND /// guids), and the instance reap deletes them. END-appended + `#[default(0u64)]` (danger-zones §2 /// additive migration). GATEWAY-SUBSCRIBED table → the binding /// (`gateway/src/stdb/bindings/game_object_type.rs`) and the `schema_parity.rs` manifest are - /// hand-synced in the SAME change (playbook failure-mode #1). + /// hand-synced in the SAME change (playbook failure-mode). #[default(0u64)] pub instance_id: u64, /// AOI grid cell (246): stamped from (x, y) at insert — GOs are static, no re-stamp. The @@ -249,7 +222,7 @@ pub struct GameObject { /// column of that index is matched by an equality term, and it skips any index with more than 3 /// columns outright (`MAX_EXACT_INDEX_COLS`); range predicates are never index-served at all /// (`IndexProbe::Range` — "we currently never construct this variant") and an `OR` is evaluated - /// row-by-row. So a `grid_x BETWEEN .. AND grid_y BETWEEN ..` box degrades to a full partition + /// row-by-row. So a `grid_x BETWEEN.. AND grid_y BETWEEN..` box degrades to a full partition /// scan — 1.1 BILLION rows examined on `game_gameobject` in a 445-player measurement. Folding the /// two grid columns into one makes `by_cell` a 3-column all-equality index, which the planner CAN /// serve, and the AOI box becomes 25 point probes instead of a scan. @@ -354,7 +327,7 @@ pub struct GameObjectPool { } /// A pool member POINT (cmangos `pool_gameobject` subset). A *potential* spawn location: a point is -/// ACTIVE iff a `game_gameobject` row exists at `pool_point_guid(point_id)`, INACTIVE otherwise — the +/// TIVE iff a `game_gameobject` row exists at `pool_point_guid(point_id)`, INACTIVE otherwise, the /// active/inactive distinction is the EXISTENCE OF A ROW, not a column (so the spawn row is untouched). /// `template_entry` chooses which GO spawns here (a higher-tier template = a rarer member); `weight` is /// the relative selection weight (0 = never auto-selected). Public + SQL-loadable (no Timestamp). @@ -468,7 +441,7 @@ pub(crate) fn reroll_pool(ctx: &ReducerContext, pool_id: u32, gathered_guid: u64 activate_point(ctx, chosen); } -/// ACTIVATE a pool point — insert its READY `game_gameobject` row at the derived guid. Shared by +/// TIVATE a pool point, insert its READY `game_gameobject` row at the derived guid. Shared by /// `arm_pool` (initial fill) and `reroll_pool` (rotation). Idempotent-by-guid: the caller guarantees /// the point is currently inactive (no row at its guid), so this is a plain insert. fn activate_point(ctx: &ReducerContext, m: &GameObjectPoolMember) { @@ -512,26 +485,10 @@ fn imported_terrain_maps(ctx: &ReducerContext) -> HashSet { .collect() } -/// Whether a pool member's map is one this database's real imported content covers (pure, unit-tested -/// — see `imported_terrain_maps` for how `imported_maps` is derived from live tables). An EMPTY -/// `imported_maps` means nothing has been imported here yet, so `init`'s own publish-time `arm_pool` -/// call (and a bare-dev-DB `debug_setup_gather_pool`) must still arm every pool — this admits -/// everything in that case. Once ANY map has real terrain/nav content, a member on a DIFFERENT map is -/// foreign: refusing it is what stops `arm_all_pools` resurrecting another continent's fixture after -/// that continent's own import already wiped it (the false REGRESSION a clean map-1 import -/// used to end with, because the demo tier-pool's map-0 Northshire point got re-armed regardless). pub(crate) fn pool_member_map_is_live(member_map: u32, imported_maps: &HashSet) -> bool { imported_maps.is_empty() || imported_maps.contains(&member_map) } -/// The exact candidate filter `arm_pool` splices into its `&&` chain each iteration — weight>0, on a -/// live map (`pool_member_map_is_live`), and not already an active point (`active_point_guids`, the -/// pool's currently-armed point guids at `pool_point_guid`). Pulled out to a pure fn (no -/// `ReducerContext`, `members`/`active_point_guids` are plain owned/borrowed values a unit test can -/// construct directly) SPECIFICALLY so this splice point — not just the isolated -/// `pool_member_map_is_live` predicate — has an automated mutation-testing guard (review: the -/// original review disclosed this wiring as unreachable without a live `ReducerContext`; it isn't — -/// `arm_pool` below now calls this directly, so mutating IT mutates the real production code path). pub(crate) fn eligible_pool_candidates( members: Vec, active_point_guids: &HashSet, @@ -719,7 +676,7 @@ pub(crate) fn usable_go( Ok((go, tmpl)) } -/// Pick the lock on a locked GameObject (work-item 119 — Pick Lock 1804, gateway-intercepted by the +/// Pick the lock on a locked GameObject, Pick Lock 1804, gateway-intercepted by the /// E_OPEN_LOCK effect kind). Gated same-map/instance + range like [`apply_use_gameobject`] (the caster /// walked up to click it). Reads the GO template's `lock_id`, then scans `game_lock` for a SATISFIABLE /// real opener — a SKILL row (kind 2, `property != 0`) the caster's `game_player_skill(property)` meets, @@ -829,7 +786,7 @@ fn use_resolved_gameobject( (go.guid, go.template_entry, go.instance_id); match tmpl.type_id { go_type::CHEST => { - // LOCK gate (work-item 119): a CHEST whose lock has a REAL opener (`locked_shut`) refuses + // LOCK gate : a CHEST whose lock has a REAL opener (`locked_shut`) refuses // the loot roll until Pick Lock (`apply_pick_lock`) records a `game_gameobject_unlocked` row. // lock_id 0 / a hand-open (property==0-only) lock opens freely (unchanged). BEFORE the loot. if locked_shut(ctx, go.guid, tmpl.lock_id) { @@ -838,14 +795,7 @@ fn use_resolved_gameobject( if go.state != 0 { return Err("chest already looted".to_string()); } - // DATA-DRIVEN (work-item 210): `data1` names a `game_gameobject_loot` loot id (the REAL - // cmangos `gameobject_template.Data1`) — roll ALL independent rows + one pick per group as - // real multi-slot `game_corpse_loot` rows, keyed on the GO guid so the existing CMSG_LOOT/ - // take-loot path serves it unchanged. Quest-only rows roll UNCONDITIONALLY now (work-item - // 187 slice 0 — no user/killer gate; visibility/takability are per-viewer/per-taker - // downstream, same as the creature/pickpocket families). `data1 == 0` (unimported, or the - // seed/demo chest) OR a table whose roll produced NOTHING falls back to the legacy single - // guaranteed `data0` drop, so existing seed/demo data + tests stay byte-identical. + let raw: Vec<(u32, u32, u32, u32, bool)> = if tmpl.data1 != 0 { ctx.db .game_gameobject_loot() @@ -914,7 +864,7 @@ fn use_resolved_gameobject( // // (1) ROAMING pool (a pooled point whose pool is `in_place == false`) → the rotation IS the // respawn: `reroll_pool` NOW (delete this point, activate a DIFFERENT inactive one) and RETURN - // — no state flip, no armed timer (reroll_pool deleted the row, so pass_gameobject_respawn + // , no state flip, no armed timer (reroll_pool deleted the row, so pass_gameobject_respawn // never sees this guid → no double-respawn). Deleting the point relays SMSG_DESTROY_OBJECT // (vanishes on gather = correct feedback) and the new point's INSERT relays CREATE_OBJECT (the // node roams visible now). Active count stays max_active. DEBUG-ONLY: vanilla Elwynn is not @@ -965,10 +915,10 @@ fn use_resolved_gameobject( } } go_type::DOOR | go_type::BUTTON => { - // DOOR/BUTTON toggle (work-item 211): open↔closed is state 0↔1, the same field the + // DOOR/BUTTON toggle : open↔closed is state 0↔1, the same field the // gateway already relays in the CREATE_OBJECT (`gameobject_state`) — see the new // `on_go_update` relay (subscriptions.rs) that re-emits it live, mirroring the corpse - // body→bones re-emit. LOCK gate (work-item 119): a DOOR/BUTTON whose lock has a REAL opener + // body→bones re-emit. LOCK gate : a DOOR/BUTTON whose lock has a REAL opener // (`locked_shut`) refuses the toggle until Pick Lock records a `game_gameobject_unlocked` // row. lock_id 0 / a hand-open (property==0-only) lock toggles freely (unchanged, per 211). if locked_shut(ctx, go.guid, tmpl.lock_id) { @@ -978,13 +928,13 @@ fn use_resolved_gameobject( ctx.db.game_gameobject().guid().update(go); } _ => { - // INERT type (work-item 211 widened import): every in-scope cmangos GO type now imports + // INERT type (widened import): every in-scope cmangos GO type now imports // (template + spawn), but only CHEST/GOOBER/GATHER/DOOR/BUTTON dispatch above — everything - // else (SPELL_FOCUS, MAILBOX until work-item 068, etc.) is a benign no-op so the object is + // else (SPELL_FOCUS, MAILBOX until, etc.) is a benign no-op so the object is // at least usable/clickable without a hard error, not a silent capability promise. } } - // Encounter kernel notify hook (work-item 228): every SUCCESSFUL use — the gates above (range, + // Encounter kernel notify hook : every SUCCESSFUL use, the gates above (range, // already-looted, skill) `?`-returned before this line, so a rejected use never fires. crate::hooks::fire_on_go_used( ctx, @@ -1147,7 +1097,7 @@ pub fn debug_pick_lock_entry( /// /// `packed`: rows separated by `;`, fields by `,`, in the order /// `guid,template_entry,map_id,x,y,z,orientation,initial_state,rot0,rot1,rot2,rot3`. `created_at` = -/// `ctx.timestamp`. `initial_state` (work-item 211, END field — widened alongside +/// `ctx.timestamp`. `initial_state`, END field, widened alongside /// `import_gameobjects_append` in the SAME commit, not a table migration: this is a string protocol /// between the importer and this reducer, not a reducer arg) lets a DOOR/BUTTON spawn already-open /// (cmangos `startOpen`); every other type's importer row carries 0 (ready/closed), byte-identical to @@ -1180,14 +1130,6 @@ pub fn import_gameobjects_append(ctx: &ReducerContext, packed: String) -> Result Ok(()) } -/// Parse `packed` (`;`-separated rows of -/// `guid,template_entry,map,x,y,z,o,initial_state,rot0,rot1,rot2,rot3`) into `game_gameobject` rows -/// (stamped at `ctx.timestamp`), returning the count loaded. Shared by both reducers above. -/// `initial_state` is work-item 211's DOOR/BUTTON `startOpen` carry — every non-door/button importer -/// row still sends 0, so existing content is byte-identical. `rot0..3` is the cmangos -/// spawn quaternion; the importer sends the dump's literal `0,0,0,0` for any row it doesn't have one -/// for, so this reducer never needs to derive a fallback itself — that derivation is the WIRE codec's -/// job (`gateway/src/codec/gameobject.rs`), not storage's. fn load_go_batch(ctx: &ReducerContext, packed: &str) -> Result { let gos = ctx.db.game_gameobject(); let now = ctx.timestamp; @@ -1204,10 +1146,7 @@ fn load_go_batch(ctx: &ReducerContext, packed: &str) -> Result { let pu32 = |s: &str| s.parse::().map_err(|_| format!("bad u32: {s}")); let pf32 = |s: &str| s.parse::().map_err(|_| format!("bad f32: {s}")); let pu8 = |s: &str| s.parse::().map_err(|_| format!("bad u8 state: {s}")); - // Parse the position ONCE: the grid columns used to re-parse `f[3]`/`f[4]` per - // component, and `cell` would have made that four parses of the same two fields. Hoisting - // also makes it structurally impossible for `grid_x`, `grid_y` and `cell` below to be - // derived from different coordinates. + let (gx_src, gy_src) = (pf32(f[3])?, pf32(f[4])?); let go = gos .try_insert(GameObject { @@ -1222,8 +1161,7 @@ fn load_go_batch(ctx: &ReducerContext, packed: &str) -> Result { created_at: now, respawn_at_micros: 0, // a freshly-imported node is ready (no pending respawn) instance_id: 0, // imported static rows are open-world (dungeon copies are runtime, 190 slice 2) - // note: the new respawn_secs/gather_gray cols live on the TEMPLATE (GameObjectTemplate), not on - // this SPAWN row — so this GameObject literal is otherwise unchanged this slice., + grid_x: lyracore_shared::spatial::grid_cell(gx_src, gy_src).0, grid_y: lyracore_shared::spatial::grid_cell(gx_src, gy_src).1, cell: lyracore_shared::spatial::cell_id_at(gx_src, gy_src), @@ -1261,7 +1199,7 @@ mod tests { assert_eq!(go_type::GATHER, 25); assert_ne!(go_type::GATHER, go_type::CHEST); assert_ne!(go_type::GATHER, go_type::GOOBER); - // DOOR/BUTTON (work-item 211) sit at cmangos' real 0/1 — guard them too, and confirm none of + // DOOR/BUTTON sit at cmangos' real 0/1, guard them too, and confirm none of // the five LIVE type ids collide with each other. assert_eq!(go_type::DOOR, 0); assert_eq!(go_type::BUTTON, 1); @@ -1283,7 +1221,7 @@ mod tests { } } - /// Work-item 041: `QUESTGIVER` is re-exported from `lyracore_shared::constants::go_type` (not a local + /// `QUESTGIVER` is re-exported from `lyracore_shared::constants::go_type` (not a local /// literal) so the module and gateway copies can never drift — pin BOTH the cmangos value and the /// identity with the shared source in one assertion. #[test] @@ -1295,7 +1233,7 @@ mod tests { ); } - /// DOOR/BUTTON TOGGLE (work-item 211) — the pure decision `apply_use_gameobject`'s DOOR|BUTTON arm + /// DOOR/BUTTON TOGGLE, the pure decision `apply_use_gameobject`'s DOOR|BUTTON arm /// calls. Total over every `u8` (not just 0/1): only exact 0 flips to 1, anything else (a corrupt/ /// foreign value) flips to 0 rather than panicking or wedging the reducer. #[test] @@ -1307,7 +1245,7 @@ mod tests { assert_eq!(toggle_state(255), 0); } - /// LOCK opener classification (work-item 119) — the pure decision the use-gate + pick path share. A + /// LOCK opener classification, the pure decision the use-gate + pick path share. A /// SKILL row (kind 2) is a REAL opener only with a real SkillLine (`property != 0`): the importer /// stores an UNMAPPED LockType.dbc id as `property 0` (LOCKTYPE_OPEN/blasting/disarm), which must NOT /// gate a hand-open chest and which Pick Lock can't satisfy. An ITEM row (kind 1) always opens; an @@ -1397,10 +1335,6 @@ mod tests { assert_eq!(respawn_window_micros(180), RESPAWN_WINDOW_MICROS); // 180s == the const, no surprise } - /// `arm_pool`'s map-fence. An EMPTY imported-maps set (nothing has ever been really - /// imported into this database) must admit every member regardless of its map: that's `init`'s own - /// publish-time arm, and a bare-dev-DB `debug_setup_gather_pool`, and both must keep working exactly - /// as before this fence existed. #[test] fn pool_member_map_is_live_admits_everything_before_any_real_import() { let none_imported: HashSet = HashSet::new(); @@ -1444,10 +1378,6 @@ mod tests { assert!(!pool_member_map_is_live(1, &multi_map)); } - /// `eligible_pool_candidates` — the ACTUAL splice `arm_pool` calls each iteration (review: - /// the disclosed gap was that only the isolated `pool_member_map_is_live` predicate had a unit test, - /// not the wiring; this exercises the real production filter — weight>0 AND live-map AND not-already- - /// active — together, exactly as `arm_pool` composes them). #[test] fn eligible_pool_candidates_applies_weight_map_and_already_active_together() { fn member(point_id: u64, map_id: u32, weight: u32) -> GameObjectPoolMember { diff --git a/module/src/gc.rs b/module/src/gc.rs index 18e34892f..d9088f45e 100644 --- a/module/src/gc.rs +++ b/module/src/gc.rs @@ -160,11 +160,11 @@ pub fn reap_movement_events(ctx: &ReducerContext, _schedule: EventReaperSchedule } } - // CC diminishing-returns windows (work-item 192): rows whose 15s post-removal window has + // CC diminishing-returns windows : rows whose 15s post-removal window has // elapsed are dead state — the next same-category CC starts fresh at level 1 anyway. crate::spell::stacking::sweep_dr_state(ctx); - // Need/greed roll deadlines (work-item 187): resolves any roll whose 60s window lapsed with + // Need/greed roll deadlines : resolves any roll whose 60s window lapsed with // votes outstanding (absent members auto-pass) — the deadline half of resolve-exactly-once. crate::loot::sweep_loot_rolls(ctx); diff --git a/module/src/gm.rs b/module/src/gm.rs index 1f6d922ed..a97e54694 100644 --- a/module/src/gm.rs +++ b/module/src/gm.rs @@ -1,4 +1,4 @@ -//! GM playtest dot-commands (work-item 223): `.speed/.god/.xprate/.level/.money/.heal/.kill/.tele` +//! GM playtest dot-commands : `.speed/.god/.xprate/.level/.money/.heal/.kill/.tele` //! from Say chat. The gateway's `CMSG_MESSAGECHAT` Say arm intercepts any message starting with `.` //! BEFORE the normal chat relay/insert and forwards the raw text to this ONE generic reducer //! (`gm_command`) — module-side parsing keeps the command set data-free and easily extended (a new @@ -200,12 +200,6 @@ fn authority_accepts_command_name(authority: GmAuthority, text: &str) -> bool { } } -/// Parse a raw dot-command string (`".speed 3"`, `".god"`, …) into a [`GmCommand`]. Pure — no -/// `ReducerContext`, fully unit-testable. Args are space-separated (`split_whitespace`, so repeated -/// spaces are tolerant). An out-of-range numeric arg is silently CLAMPED into the command's valid -/// range (not rejected); a non-numeric/missing arg, an unrecognized `.tele` name, or an unknown -/// command name is a hard `Err` with a user-facing message (relayed back to the sender as a system -/// chat line by the gateway on a reducer `Err`). pub(crate) fn parse_gm_command(text: &str) -> Result { let body = text .strip_prefix('.') @@ -284,9 +278,9 @@ pub(crate) fn parse_gm_command(text: &str) -> Result { // Reducers // =========================================================================================== -/// Grant/revoke GM level (operator-only CLI reducer, work-item 223 authz): `level` gates +/// Grant/revoke GM level (operator-only CLI reducer, authz): `level` gates /// [`gm_command`] (`0` = no access — the moderation-facing per-level distinctions beyond "has -/// access at all" are work-item 205's concern, not this one's). Looked up by character NAME +/// access at all" are's concern, not this one's). Looked up by character NAME /// (case-insensitive, as `/w bob` reaches Bob) since an operator drives this via /// `spacetime call`, which has no live guid to hand. `require_operator` gates it exactly like the /// importer reducers — never player-callable. @@ -404,7 +398,7 @@ pub(crate) fn apply_gm_command( // `map_id` comes from the destination, so a cross-map `.tele` (ValleyOfTrials, map 1) // rides the same primitive and becomes a full escrowed transfer to whichever database // owns that map. Deadmines is still deliberately absent: map 36 has no imported world - // data, so it would .tele into an empty room. + // data, so it would.tele into an empty room. crate::world::teleport_player(ctx, caller.guid, map_id, 0, x, y, z, o); } } diff --git a/module/src/graveyard.rs b/module/src/graveyard.rs index 0131ba86f..c83f18824 100644 --- a/module/src/graveyard.rs +++ b/module/src/graveyard.rs @@ -1,8 +1,3 @@ -//! Graveyard resolution (work-item 209/226): the death-release subsystem `world::do_repop` calls to -//! pick where a ghost teleports. Extracted from `world.rs` — it used to be an inline -//! `mod graveyard` sitting inside the player-entity file, a complete subsystem (five consts, zone -//! resolution, faction teams, the instance-map arm, three candidate builders, two pickers) plus its -//! own ~250-line test battery, living inside a 2,700-line file it had nothing else to do with. //! //! `GraveyardLoc`/`GraveyardZone` stay `pub` tables and every accessor/type here is re-exported at //! the crate root via `pub use graveyard::*;` in `lib.rs` (mirroring `world.rs`'s own `pub use`) — @@ -12,8 +7,8 @@ use spacetimedb::{table, ReducerContext, Table}; /// One `WorldSafeLocs.dbc` row (a graveyard's fixed position) — imported by the importer's `--dbc` -/// mode (work-item 209; see `importer/src/dbc.rs::graveyard_sql`). Replaces the hardcoded -/// `{NORTHSHIRE, GOLDSHIRE, ...}` consts below as the primary data source once imported; those +/// mode (see `importer/src/dbc.rs::graveyard_sql`). Replaces the hardcoded +/// `{NORTHSHIRE, GOLDSHIRE,...}` consts below as the primary data source once imported; those /// consts remain as the no-import fallback (`nearest`), and `seed.rs` row-seeds the SAME five points /// here too, mirroring the `game_start_position` seed/import precedent (init seeds, import /// clear+reloads over it). No orientation column — `WorldSafeLocs.dbc` carries none (a graveyard @@ -26,16 +21,14 @@ pub struct GraveyardLoc { pub x: f32, pub y: f32, pub z: f32, - /// Ghost-spawn facing. WorldSafeLocs.dbc carries none (importer writes 0.0); the SEED rows keep - /// the consts' verified orientations — without this column the DB path silently dropped - /// Northshire's 2.72271 facing on every fresh init (review catch). + pub o: f32, pub name: String, } /// One cmangos `game_graveyard_zone` row — links a `game_graveyard.id` (`safe_loc_id`) to the zone /// it serves, with an optional faction restriction (0 = both factions; else the cmangos team-faction -/// id, see `team_for_race`). Imported by the importer's `--dump` mode (work-item 209; see +/// id, see `team_for_race`). Imported by the importer's `--dump` mode; see /// `importer/src/main.rs::build_graveyard_zone_sql`); `resolve_graveyard` reads it via `by_zone` to /// prefer a zone-linked graveyard over a merely-closer unlinked one (the cmangos release rule). No /// Timestamp → plain SQL. [static] @@ -53,12 +46,6 @@ pub struct GraveyardZone { pub faction: u32, } -/// A single graveyard release point — either one of the hardcoded Elwynn/Westfall fallback consts -/// below, or one resolved from the imported `game_graveyard`/`game_graveyard_zone` tables (work-item -/// 209). Both paths converge on this same shape so `nearest_of`/`pick_graveyard` never care which -/// source a candidate came from. `pub(crate)` (not just module-private) and its fields likewise: this -/// crosses back into `world.rs`'s `do_repop`, which reads `gy.map/x/y/z/o` off `resolve_graveyard`'s -/// return value. #[derive(Clone, Copy)] pub(crate) struct Graveyard { pub(crate) map: u32, @@ -95,7 +82,7 @@ const EASTVALE: Graveyard = Graveyard { o: 0.0, }; -// world_safe_locs id ≈80 [V] — Sentinel Hill (Westfall, zone 40). Hand-added for work-item 206 +// world_safe_locs id ≈80 [V], Sentinel Hill (Westfall, zone 40). Hand-added for // (the Westfall 1-20 slice); coords are an UNVERIFIED estimate (this sandbox has no reference // world-database to read the real row from); orientation defaults to 0.0 (not sourced). CONFIRM // x/y/z against your own imported world_safe_locs before relying on this for a live release. @@ -125,25 +112,9 @@ const STATIC_CANDIDATES: [Graveyard; 5] = [ WESTFALL_COAST, ]; -// ---- Instance-map release (work-item 226) -------------------------------------------------- +// ---- Instance-map release -------------------------------------------------- // -// Deadmines (map 36) has ADT tiles, but its stacked floors do not fit the imported single-height -// terrain rows. `terrain::zone_id_at` therefore cannot resolve a zone there, -// and it has NO graveyards of its own (`all_on_map(36)` is empty) — the pre-226 chain therefore -// fell all the way to `nearest(px, py)`, comparing map-36 interior coordinates against map-0 -// consts by raw 2-D distance: a meaningless pick (a Deadmines death "released" at Northshire). -// The vanilla rule for instance deaths is the EXTERNAL graveyard linked to the instance's own -// zone id in `game_graveyard_zone` (cmangos `ghost_zone`) — which lives on ANOTHER map (Deadmines -// zone → a Westfall graveyard on map 0), so the instance arm deliberately does NOT map-filter its -// candidates the way `zone_linked` does. The release itself riding cross-map is exactly the 224 -// teleport + the 226 pending_ghost preservation (see `world::persisted_pending_ghost`). -/// The instance map's own zone id (cmangos `game_graveyard_zone.ghost_zone` for deaths inside) — -/// the map→zone hop `terrain::zone_id_at` can't make without terrain. `None` = not an instance map → -/// the normal open-world chain runs untouched. A field read off -/// `crate::instance::DUNGEON_MAPS`, the one dungeon-detail record shared with -/// `instance_static_fallback` below and `crate::instance::entrance_fallback` — see that table's -/// doc for the Deadmines/zone-1581 provenance note this used to carry directly. pub(crate) fn instance_release_zone(map_id: u32) -> Option { crate::instance::dungeon(map_id).map(|d| d.release_zone) } @@ -161,12 +132,6 @@ pub(crate) fn instance_static_fallback(map_id: u32) -> Option { }) } -/// The pure instance-release pick: zone-linked external candidates first (nearest by 2-D distance -/// if several — a DETERMINISTIC tie-break, not a meaningful one: the coords compared are the -/// death position INSIDE the instance vs candidates on the external map; vanilla keys the choice -/// off the entrance instead. Moot with the expected single link row — 226 review), else the -/// static per-map fallback. `None` = not handled as an instance release (caller falls through to -/// the normal chain). Pure/testable — the DB read lives in `resolve_graveyard`. pub(crate) fn pick_instance_graveyard( linked: &[Graveyard], static_fallback: Option, @@ -198,13 +163,6 @@ pub(crate) fn nearest(px: f32, py: f32) -> Graveyard { nearest_of(&STATIC_CANDIDATES, px, py).unwrap_or(NORTHSHIRE) } -/// Pick a release point per the cmangos rule, given already-resolved candidate lists: prefer a -/// ZONE-LINKED graveyard (`zone_linked` — faction-filtered rows from `game_graveyard_zone` for -/// the death position's zone) even over a geometrically closer graveyard outside that zone; if -/// none is zone-linked (an unresolved zone, or an empty/unimported table), fall back to the -/// nearest of EVERY imported graveyard (`all`); if that's empty too (nothing imported), fall back -/// to the static consts. Pure/testable — no `ReducerContext` — the DB reads live in -/// `resolve_graveyard` below. pub(crate) fn pick_graveyard( zone_linked: &[Graveyard], all: &[Graveyard], @@ -222,8 +180,6 @@ pub(crate) fn pick_graveyard( /// with this one only until somebody added a race to one of them. pub(crate) use lyracore_shared::faction::team_for_race; -/// Convert one `game_graveyard` row into the pure [`Graveyard`] shape every candidate source -/// converges on — the identical 7-line map every caller below used to repeat inline. fn to_gy(g: GraveyardLoc) -> Graveyard { Graveyard { map: g.map_id, @@ -238,19 +194,6 @@ fn to_gy(g: GraveyardLoc) -> Graveyard { /// resolved to positions via `game_graveyard`. A `game_graveyard_zone` row whose `safe_loc_id` has /// no matching `game_graveyard` row is silently dropped — an inconsistent import shouldn't panic a /// release. -/// -/// `map_filter`: `Some(map_id)` restricts to that map — the open-world call (never cross-map; see -/// the RESOLVED note below). `None` doesn't filter — the instance-release arm (work-item 226): a -/// dungeon zone's linked graveyard is deliberately on ANOTHER map (Deadmines zone → Westfall, map -/// 0), which is precisely what the map filter exists to prevent for open-world zones.: -/// this used to be two near-identical functions (`zone_linked`/`zone_linked_cross_map`, the second -/// the first minus one `.filter()`); collapsed into one. -// NOTE (190 slice 2, RESOLVED — no instance gate needed here): graveyard data is static, -// instance-0-by-nature, and the `Some(map_id)` (open-world) call only ever serves releases on -// NON-instance maps — an instance-map death routes through `resolve_graveyard`'s instance arm -// above (226) with `map_filter: None`, whose release teleport always lands at instance 0 -// (`world::do_repop` passes 0 explicitly). The corpse itself keeps its own `instance_id` (stamped -// in `do_repop`) for the run-back/reclaim. fn zone_linked( ctx: &ReducerContext, zone_id: u32, @@ -292,22 +235,7 @@ pub(crate) fn resolve_graveyard( race: u8, ) -> Graveyard { let team = team_for_race(race); - // Instance-map arm (work-item 226): a death INSIDE a dungeon releases to the EXTERNAL - // graveyard linked to the instance's own zone (cross-map — see the section comment above), - // falling back to the per-map static const when nothing is imported. Handled BEFORE the - // open-world chain, whose every step (terrain zone resolve / same-map candidates / raw - // nearest-const) is meaningless inside a WMO map. - // The "map_id != 0 with no instance arm" runtime `warn!` this branch used to - // carry is gone — it existed because `instance_release_zone` and `instance_static_fallback` - // used to be TWO independently-hand-matched functions that could disagree on which maps were - // "instance maps", and the warn was the only way that disagreement surfaced (a garbled - // literal, at that: rustfmt had wrapped mid-sentence). Both are now field reads off the same - // `crate::instance::DUNGEON_MAPS` record, so `instance_release_zone(map_id).is_some()` and - // `instance_static_fallback(map_id).is_some()` agree by construction — when the former is - // `Some`, the latter always is too, so `pick_instance_graveyard` below always returns and - // this arm never falls through. A non-dungeon non-open-world map (e.g. a future continent - // with no Deadmines-shaped release data) is simply not in `DUNGEON_MAPS` and correctly takes - // the open-world chain below with no warning — it was never an omission to warn about. + if let Some(zone_id) = instance_release_zone(map_id) { let linked = zone_linked(ctx, zone_id, team, None); if let Some(g) = pick_instance_graveyard(&linked, instance_static_fallback(map_id), x, y) { @@ -400,7 +328,7 @@ mod tests { // (~1180yd) — the exact wrong-direction corpse run `nearest` exists to avoid. let s = nearest(-9500.0, -1000.0); assert_eq!((s.x, s.y), (-9552.73, -1374.84)); - // work-item 206: a Westfall-ish death point (near Sentinel Hill) picks Sentinel Hill over + // a Westfall-ish death point (near Sentinel Hill) picks Sentinel Hill over // Eastvale — the nearest Elwynn graveyard is nowhere close once Westfall is in the candidate // set (Sentinel Hill ~95yd away vs Eastvale's ~2700yd), proving the two new graveyards // actually widen the release map rather than sitting unreachable in the candidate slice. @@ -410,7 +338,7 @@ mod tests { #[test] fn pick_graveyard_prefers_a_zone_linked_graveyard_over_a_closer_unlinked_one() { - // work-item 209: game_graveyard_zone's whole point is that a graveyard EXPLICITLY linked to + // game_graveyard_zone's whole point is that a graveyard EXPLICITLY linked to // the death position's resolved zone wins even when an unlinked (or wrong-zone) graveyard // sits geometrically closer — the cmangos release rule. let close_unlinked = Graveyard { diff --git a/module/src/group.rs b/module/src/group.rs index 6310b21db..78f846c2e 100644 --- a/module/src/group.rs +++ b/module/src/group.rs @@ -48,13 +48,6 @@ use lyracore_shared::group::{ /// nor quest credit. Vanilla's `sWorld.getConfig(CONFIG_FLOAT_GROUP_XP_DISTANCE)` = 74.0 yd. pub const GROUP_XP_RANGE_SQ: f32 = 74.0 * 74.0; -/// Loot-method encoding for `Group.loot_method` (work-item 187) — deliberately WIRE-MATCHING: these -/// values are byte-identical to vanilla's `GroupLootSetting` enum (`wow_world_base`: -/// `FreeForAll=0, RoundRobin=1, MasterLoot=2, GroupLoot=3, NeedBeforeGreed=4`), NOT the work item's -/// own draft numbering (which listed `2=GROUP default, 3=MASTER` — the OPPOSITE of the real wire -/// order). Adopting the wire order here means the gateway's `u8 <-> GroupLootSetting` conversion is a -/// straight pass-through with zero translation table — avoiding the exact silent-drift shape an -/// invented enum ordering that doesn't match the wire would cause. pub mod loot_method { pub const FFA: u8 = 0; pub const ROUND_ROBIN: u8 = 1; @@ -75,13 +68,7 @@ pub struct Group { #[auto_inc] pub group_id: u64, pub leader_guid: u64, - // END-APPENDED (work-item 187): the party's current loot method/threshold/master + the - // round-robin cursor. `#[default(...)]` so every pre-187 row keeps behaving as plain FFA-like - // "no restriction" — wait: the vanilla DEFAULT party loot method is GROUP LOOT (Uncommon - // threshold), not FFA, so a pre-187 group (created before this slice) auto-migrates to GROUP — - // matching what a freshly-formed vanilla party actually defaults to. `master_looter_guid` and - // `rr_cursor` default to 0 regardless of method (meaningless until MASTER is actually selected / - // the first round-robin kill advances the cursor). + #[default(3)] // loot_method::GROUP pub loot_method: u8, #[default(2)] // ItemQuality::Uncommon @@ -160,9 +147,9 @@ pub enum PartyPartitionState { crate::character_owned!(delete, fn sweep_delete_game_group_member_partition(ctx, character_guid) { ctx.db - .game_group_member_partition() - .character_guid() - .delete(character_guid); +.game_group_member_partition() +.character_guid() +.delete(character_guid); }); crate::character_owned!(not_transported, fn sweep_transfer_game_group_member_partition()); @@ -186,7 +173,7 @@ crate::character_owned!(restamp, fn sweep_restamp_game_group_member(ctx, charact // // Shipped an interim MIRROR here — the member row travelled in the manifest carrying its // original `group_id`, and an `ensure_group` helper re-created the parent `game_group` row at the -// destination. Its own AC#3 called for that mirror to be deleted once membership had one +// destination. Its own AC called for that mirror to be deleted once membership had one // authoritative home, and the group slice gives it one: `game_group` / `game_group_member` / `game_group_invite` // are authoritative on REALM-CORE, and each world shard's copy is a gateway-maintained write-through // cache (`sync_group_mirror` below) — the same relationship `game_account` / `game_session` have. @@ -210,10 +197,6 @@ crate::character_owned!(not_transported, fn sweep_transfer_game_group_member()); /// portal. On realm-core that would be worse than wrong — it would be a MIRROR inventing a membership /// change and notifying clients about it, when the authority (realm-core) recorded nothing at all. /// Deleting the row and nothing else is exactly what a departing cache entry should do. -/// -/// The residue an empty `game_group` row used to leave here is now swept by the gateway's own -/// `sync_group_mirror` push, which replaces this shard's whole copy of the party on the next op or -/// world entry (and deletes the group row outright when the party disbands). pub(crate) fn detach_for_transfer(ctx: &ReducerContext, character_guid: u64) { let members = ctx.db.game_group_member(); for m in members @@ -255,10 +238,10 @@ crate::character_owned!(delete, fn sweep_delete_game_group_invite(ctx, character invites.id().delete(inv.id); } let sent: Vec = invites - .iter() - .filter(|i| i.inviter_guid == character_guid) - .map(|i| i.id) - .collect(); +.iter() +.filter(|i| i.inviter_guid == character_guid) +.map(|i| i.id) +.collect(); for id in sent { invites.id().delete(id); } @@ -1108,7 +1091,7 @@ fn led_group_of(ctx: &ReducerContext, guid: u64) -> Result<(GroupMember, Group), Ok((m, group)) } -/// `pub(crate)` (was private): work-item 187's kill-time loot stamping (`combat/mod.rs` → +/// `pub(crate)` (was private):'s kill-time loot stamping (`combat/mod.rs` → /// `loot::apply_group_loot_rules`) needs the CURRENT roster to pick a round-robin/master designee. pub(crate) fn members_of(ctx: &ReducerContext, group_id: u64) -> Vec { ctx.db @@ -1289,13 +1272,7 @@ fn invite_core_on( if target_guid == inviter_guid { return Err(GroupRefusal::InviteSelf.into()); } - // EXISTENCE + PRESENCE are the two gates that need a database holding characters and live - // entities, so they are the two the directory plane cannot run: realm-core has - // neither table populated, and a shard's copy only knows about its own players — which is the - // whole bug this slice fixes (a target inside Deadmines "does not exist" to the open world). - // On REALM-CORE the gateway has already resolved both ACROSS every connected shard before - // calling (`world::party::resolve_target`), and it answers with the same two Refusals, so the - // player-visible answer is unchanged. + if plane == Plane::Shard { if ctx.db.game_character().guid().find(target_guid).is_none() { return Err(GroupRefusal::NoSuchPlayer.into()); @@ -1600,8 +1577,6 @@ fn member_identity(ctx: &ReducerContext, character_guid: u64) -> Identity { ) } -/// The identity-free decline core: the body `group_decline` used to inline, so the realm-core -/// plane runs the SAME code rather than a second implementation of it. pub(crate) fn decline_invite_for(ctx: &ReducerContext, decliner_guid: u64) -> Result<(), String> { decline_invite_on(ctx, decliner_guid).map_err(|error| { group_op_error( @@ -1629,7 +1604,6 @@ fn decline_invite_on(ctx: &ReducerContext, decliner_guid: u64) -> Result<(), Gro Ok(()) } -/// The identity-free leave core — the body `group_leave` used to inline. pub(crate) fn leave_group_for(ctx: &ReducerContext, leaver_guid: u64) -> Result<(), String> { leave_group_on(ctx, leaver_guid, leave_cause::LEFT) .map_err(|error| group_op_error(error, &format!("{leaver_guid} could not leave its party"))) @@ -1648,7 +1622,6 @@ fn leave_group_on(ctx: &ReducerContext, leaver_guid: u64, cause: u8) -> Result<( Ok(()) } -/// The identity-free kick core — the body `group_uninvite` used to inline. pub(crate) fn uninvite_from_group( ctx: &ReducerContext, actor_guid: u64, @@ -1686,7 +1659,6 @@ fn uninvite_on( Ok(()) } -/// The identity-free loot-method core — the body `group_loot_method` used to inline. pub(crate) fn set_loot_method_for( ctx: &ReducerContext, leader_guid: u64, @@ -1970,7 +1942,7 @@ pub(crate) fn remove_member(ctx: &ReducerContext, character_guid: u64, departure group.as_ref().map_or(0, |g| g.leader_guid), ) { None => { - // Work-item 187 trap: a group disbanding mid-roll must resolve any of its live rolls + // trap: a group disbanding mid-roll must resolve any of its live rolls // to the sole remaining member (vanilla behavior) rather than leaving them to time out. // The FULL former membership (the leaver, already removed above, PLUS whoever's left) is // what `force_resolve_rolls_for_disband` needs to recognize "every voter on this roll @@ -2083,15 +2055,6 @@ pub(crate) fn leader_after_removal( // =========================================================================================== /// Which DEPLOYMENT of this one module a group call is running on. -/// -/// Realm-core is not a different crate — it is this same wasm published under a second database name -/// (`realm_core.rs`'s header). Every table exists on both; what differs is which rows are ever -/// written there. On a WORLD shard `game_character` and `game_world_entity` are populated, so the -/// existence/presence gates read them. On REALM-CORE neither is (the directory database holds -/// accounts, sessions, the character→shard index and — from this slice — party membership), so those -/// two gates have no data to run against and the GATEWAY supplies their answers instead, resolved -/// across every connected shard. Nothing else about the rules changes, which is the point: the same -/// three cores run on both planes. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub(crate) enum Plane { /// A world shard: characters and live entities are local. @@ -2785,12 +2748,6 @@ pub(crate) fn kill_reward_recipients( .collect() } -/// A single group member's kill-reward eligibility: alive, on the kill's map AND instance -/// (`same_instance` — work-item 190 slice 1, always true this slice since every entity is -/// instance 0), and within the group-XP range of the corpse (`dist_sq` = squared 2-D distance, -/// compared INCLUSIVELY against [`GROUP_XP_RANGE_SQ`]). The per-member gate of -/// [`kill_reward_recipients`], extracted so the eligibility rules are unit-testable without a -/// `ReducerContext`. Pure. pub(crate) fn eligible_for_kill_reward( dead: bool, same_map: bool, @@ -3413,7 +3370,7 @@ mod tests { } } - // ---- Group loot methods (work-item 187 slice 1) ---- + // ---- Group loot methods ---- #[test] fn loot_method_encoding_matches_the_real_wire_grouplootsetting_order() { diff --git a/module/src/guild/fee.rs b/module/src/guild/fee.rs index 021e79fc7..1d16df6c4 100644 --- a/module/src/guild/fee.rs +++ b/module/src/guild/fee.rs @@ -336,7 +336,7 @@ trait FeeLedger { ); } -/// Phase 1. Gates in mangos order: the NPC, the Charter template, the purse, then a bag slot for +/// Gates in mangos order: the NPC, the Charter template, the purse, then a bag slot for /// the Charter (`cm:GuildHandler.cpp:726-755`, `cm:PetitionsHandler.cpp:76-134`). fn hold_fee(purse: &mut P, hold: HeldFee) -> Result<(), FeeError> { if hold.operation_id == 0 { @@ -375,7 +375,7 @@ fn charter_fits(max_count: u32, held: usize) -> bool { max_count == 0 || held < max_count as usize } -/// Phase 2. Exactly one decision per operation id; a retry changes nothing. +/// Exactly one decision per operation id; a retry changes nothing. fn decide_fee( ledger: &mut L, operation_id: u64, @@ -445,7 +445,7 @@ fn charter_gate( Ok(()) } -/// Phase 3. A Hold with another operation id, or none, means this finish already ran. Both +/// A Hold with another operation id, or none, means this finish already ran. Both /// outcomes need the payer in the world here, so a Character in transit keeps its Hold for the /// destination. fn finish_fee( @@ -765,7 +765,7 @@ impl FeeLedger for CtxLedger<'_> { } } -/// Fee phase 1 on the payer's Home Shard: move the fee from the purse into a Fee Hold. While that +/// Fee on the payer's Home Shard: move the fee from the purse into a Fee Hold. While that /// Hold exists, the same operation id with the same request is a replay and succeeds without a /// second debit. After the finish deletes the Hold, a replay would take the fee again, so the /// Gateway never replays a hold. @@ -785,7 +785,7 @@ pub fn gw_guild_fee_hold( .map_err(FeeError::into_text) } -/// Fee phase 2 on Realm-core: commit the one decision for `operation_id`. Always Ok once a +/// Fee on Realm-core: commit the one decision for `operation_id`. Always Ok once a /// decision exists for this payer and kind, accepted or refused. #[reducer] pub fn realm_guild_fee_decide( @@ -800,7 +800,7 @@ pub fn realm_guild_fee_decide( .map_err(FeeError::into_text) } -/// Fee phase 3 on the payer's Home Shard: spend the Hold, or refund it when Realm-core refused, +/// Fee on the payer's Home Shard: spend the Hold, or refund it when Realm-core refused, /// then delete it. Without a Hold for `operation_id` it is a replay and changes nothing. #[reducer] pub fn gw_guild_fee_finish( diff --git a/module/src/gw.rs b/module/src/gw.rs index c07e9dbd8..d592d74c9 100644 --- a/module/src/gw.rs +++ b/module/src/gw.rs @@ -1,25 +1,6 @@ -//! The TRUSTED GATEWAY verb surface (stage 4a). -//! -//! One reducer family — `gw_(ctx, actor_guid, ...)` — for a gateway that holds a SHARED -//! SpacetimeDB connection instead of one connection per player. The sender-shaped player reducers -//! resolve "who is acting" from `ctx.sender()`; these resolve it from an explicit `actor_guid`, -//! because on a shared connection every call arrives from the same identity. -//! -//! Design rules (mirroring `actor.rs`, which this surface consumes): -//! - **Every reducer's first act is `require_operator`.** That is the entire trust model: the -//! shared connection's identity is the claimed operator, and a direct anonymous SpacetimeDB -//! client that bypasses the gateway is refused before any actor resolution happens. The -//! self-scan test at the bottom of this file pins this ordering for every reducer here. -//! - **The actor resolves through [`crate::helpers::acting_entity_by_guid`]** — the guid-keyed -//! twin of `entity_by_owner`, carrying the SAME in-transit transfer fence. Never `live_entity` -//! (it skips the fence) and never a bare `.guid().find(...)`. -//! - **No behavior**: each verb delegates to the same core its sender-shaped sibling calls -//! (`world::apply_movement_update`, the `actor.rs` verbs, ...). Gates live in the cores and -//! cannot drift between the two entries. -//! -//! These are THE player-verb surface: the sender-shaped (`ctx.sender`-authorized) twins -//! are deleted, and every player action reaches the module through a `gw_*` verb on the -//! gateway's privileged connection. +//! Player action reducers for the Gateway's shared Operator connection. +//! Each request requires the Operator and resolves its Session Actor before entering the owning +//! gameplay operation. Acting entity lookup applies the Transfer fence. use lyracore_shared::loot::LootBoundaryFailure; use spacetimedb::{reducer, table, Identity, ReducerContext, ScheduleAt, Table, Timestamp}; @@ -1397,8 +1378,6 @@ pub fn gw_learn_talent( crate::talent::do_learn_talent(ctx, actor_guid, learner.owner_identity, talent_id).map(|_| ()) } -/// [`crate::talent::do_reset_talents`] behind the gateway gate — the "I wish to unlearn my -/// talents." gossip option (work-item 198's respec primitive, wired to gossip). #[reducer] pub fn gw_reset_talents( ctx: &ReducerContext, diff --git a/module/src/helpers.rs b/module/src/helpers.rs index 2862be273..1585c9732 100644 --- a/module/src/helpers.rs +++ b/module/src/helpers.rs @@ -73,11 +73,11 @@ pub fn acting_entity_by_guid(ctx: &ReducerContext, guid: u64) -> Option Option ) } -/// The ONE place in the tree that asks "is this row's character mid-transfer?" and acts on the -/// answer. All three chokepoints above route through it; `guid_of` is the only thing they differ in. -/// -/// It exists because of the shape of the mutation it is defending against. Each -/// chokepoint used to compute its own `in_transit` boolean inline, and a `!` glued anywhere into -/// that expression — onto `is_in_transit`, or onto the `candidate.as_ref().is_some_and(..)` around -/// it — inverts the fence with the same identifiers in the same order, which no `.contains()` scan -/// can see. The answer used to be pinning all three bodies verbatim as strings inside a test, so -/// every legitimate edit had to be made twice. Collapsing them to one call site is the structural -/// version of that pin: there is now a single two-line expression to get wrong, and it is inside -/// `.cargo/mutants.toml`'s cargo-mutants scope — where `replace gate_by_guid -> None` is CAUGHT. -/// -/// The DECISION itself stays in [`gate_in_transit`], which is pure and pinned by a direct assertion. +/// Apply the transfer fence to a candidate row identified by its Character guid. fn gate_by_guid( ctx: &ReducerContext, candidate: Option, @@ -124,19 +112,7 @@ fn gate_by_guid( gate_in_transit(candidate, in_transit) } -/// The decision every in-transit fence in this file reduces to, pulled out pure and generic so its -/// SENSE — not just its presence — can be pinned by a direct assertion instead of a source scan. -/// -/// Every chokepoint above used to spell this as `.filter(|x| !is_in_transit(..))` at -/// the call site. A scan can confirm `is_in_transit` is called and in what order, but nothing short -/// of running the code can tell `!is_in_transit(..)` apart from `is_in_transit(..)` — same -/// identifiers, same order, opposite meaning. Dropping that one `!` made `entity_by_owner` return -/// ONLY in-transit entities and `None` for everyone else, and all 533 module tests stayed green. -/// -/// Now there is no `!` left at any call site for a mutation to flip: [`gate_by_guid`] computes -/// `in_transit` as a plain (unnegated) boolean and hands it here, where this file's -/// `gate_in_transit_refuses_an_in_transit_candidate_and_returns_a_normal_one` test pins the branch -/// directly with concrete values — swap the branches and that test fails by name. +/// A Character in transit is absent from normal gameplay reads. pub(crate) fn gate_in_transit(candidate: Option, in_transit: bool) -> Option { if in_transit { None @@ -145,10 +121,6 @@ pub(crate) fn gate_in_transit(candidate: Option, in_transit: bool) -> Opti } } -/// Squared 3D distance between two entities — the one spelling of the `dx*dx + dy*dy + dz*dz` -/// range-check shape `npc_interaction_gate` / `apply_inspect` / trainer validation used to paste. -/// (Loot's corpse variant keeps its scalar-coordinate form in `items/ops.rs` — different signature, -/// not a copy of this one.) pub(crate) fn dist_sq(a: &WorldEntity, b: &WorldEntity) -> f32 { let (dx, dy, dz) = (b.x - a.x, b.y - a.y, b.z - a.z); dx * dx + dy * dy + dz * dz @@ -210,7 +182,7 @@ pub(crate) fn player_interaction_gate( /// `game_world_entity` scan. Coverage is by WHOLE cells (`spatial::GRID_CELL_SIZE`), so the result /// is a superset of the exact circle: callers keep their own precise distance check. Cost scales /// with the neighborhood's population, not the world's. `instance_id` is a REQUIRED param (no -/// default) so the compiler finds every call site (work-item 190 slice 1) — pass the ACTING +/// default) so the compiler finds every call site, pass the ACTING /// entity's own `instance_id`; every slice-1 caller is at instance 0, so behavior is unchanged. pub(crate) fn entities_near( ctx: &ReducerContext, @@ -241,21 +213,12 @@ pub(crate) fn entities_near( } /// Is `e` in the `(map_id, instance_id)` isolation partition `entities_near` queries for? The exact -/// predicate the `by_grid` filter tuple encodes (work-item 190 slice 1) — pulled out pure so the +/// predicate the `by_grid` filter tuple encodes, pulled out pure so the /// instance dimension is unit-testable without a live `ReducerContext`. pub(crate) fn in_same_partition(e: &WorldEntity, map_id: u32, instance_id: u64) -> bool { e.map_id == map_id && e.instance_id == instance_id } -/// The nearest live entity to `origin` matching `pred`, restricted to `origin`'s OWN `(map_id, -/// instance_id)` partition via [`in_same_partition`] — a raw squared distance compared ACROSS maps -/// or instances is meaningless (two guids can be numerically close while standing on different -/// continents), so every "nearest X" scan must apply this same partition filter before comparing -/// distances. Candidates come off the `by_map` index (map-scoped, not a raw `.iter()` — the -/// partition-discipline tripwire), narrowed to the instance in-code; callers with a natural search -/// radius should prefer `entities_near` instead (grid-indexed, cheaper at scale). Used by the debug -/// "nearest" test levers (`debug_kill_nearest`, `debug_ranged_attack_nearest`, `debug_skin_nearest`) -/// and the nearest-trainer scans in `skill.rs`, which used to duplicate this loop byte-for-byte. /// /// Every one of those callers is a `debug_reducers` lever ("nearest X" is a test-harness affordance — /// the real client always names its target), so the whole helper is gated with them: a production @@ -315,7 +278,7 @@ pub fn live_entity(ctx: &ReducerContext, guid: u64) -> Result (u32, u64, i32, i32) { .unwrap_or((0, 0, 0, 0)) } -/// The same `(map_id, instance_id, grid_x, grid_y)` shape [`grid_of`] returns, read straight off an -/// already-fetched `WorldEntity` — zero `game_world_entity` lookup. The event-constructor `signal_at` -/// variants (`SpellCastEvent`/`CombatEvent`, perf catalog 2.3) and the chat broadcast reducer whose -/// sender entity is fetched up front (`send_emote`) all use this instead of -/// `grid_of` when the entity is in hand — a landed swing with a seal proc + a queued strike used to -/// pay the `grid_of` PK lookup up to twelve times over for what is, in every case, the SAME row. -/// Pulled out pure (module crate convention — no `ReducerContext` test harness) so the field order -/// can't quietly drift from `grid_of`'s: [`tests::entity_addr_matches_grid_of_field_order`] pins it. pub(crate) fn entity_addr(e: &WorldEntity) -> (u32, u64, i32, i32) { (e.map_id, e.instance_id, e.grid_x, e.grid_y) } @@ -459,11 +414,6 @@ pub(crate) mod tests { } } - /// The real regression this slice exists to prevent: two entities sharing a map AND a grid cell - /// but living in DIFFERENT instances must never both come back from the same `entities_near` - /// partition query — `in_same_partition` (the pure predicate `entities_near` actually filters - /// with) must separate them into disjoint result sets. Fails if the instance dimension is ever - /// dropped from the filter (e.g. a future edit narrows it back to `e.map_id == map_id` alone). #[test] fn entities_near_partitions_distinct_instances_on_the_same_cell_into_disjoint_sets() { let same_map = 1u32; @@ -521,15 +471,6 @@ pub(crate) mod tests { ); } - /// THE fix. `entity_by_owner` / `character_by_guid` / `character_by_name` all - /// reduce to this one pure decision, so pinning it here pins the sense of all three chokepoints - /// at once — no `ReducerContext` needed, unlike the reducers that call it. - /// - /// This is the direct behavioural equivalent of the mutation the issue was filed over: swap the - /// two branches below (`in_transit => candidate` / `else => None`) and this test fails by name. - /// The old code had no such test — only a source scan checking that `is_in_transit` appeared in - /// the right order — and a single dropped `!` at the call site satisfied that scan while - /// returning ONLY in-transit rows. #[test] fn gate_in_transit_refuses_an_in_transit_candidate_and_returns_a_normal_one() { assert_eq!( diff --git a/module/src/hooks.rs b/module/src/hooks.rs index ce60baf67..27899b388 100644 --- a/module/src/hooks.rs +++ b/module/src/hooks.rs @@ -5,7 +5,7 @@ //! core chokepoint calls exactly ONE `fire_*` fn here, which iterates the generated per-event //! registry. Handlers are NOTIFY-ONLY: they observe the payload and may act through the same //! reducer-internal fns core code uses (grant, damage, spawn, ...), but there is no veto/fold — -//! mutating/decorator hooks are a separately-green-lit Phase 2. +//! These hooks do not veto or fold a result. //! //! The catalog: //! @@ -27,9 +27,9 @@ //! | `on_character_relocated` | `world::teleport_player`, after placement and motion cleanup | [`CharacterRelocatedPayload`] | //! | `on_logout` | `world::remove_from_world` — covers explicit logout AND abrupt disconnect | [`LogoutPayload`] | //! | `on_gossip_select` | `world::gossip_select` — the notify reducer the gateway calls on CMSG_GOSSIP_SELECT_OPTION | [`GossipSelectPayload`] | -//! | `on_creature_death` | `combat::kill_creature`, non-pet branch — the encounter-grade twin of `on_death` (entry + instance snapshot; work-item 228) | [`CreatureDeathPayload`] | -//! | `on_hp_threshold` | `encounter::encounter_hp_probe` — the kernel's own `on_damage_taken` handler; fires once per registered `(entry, pct)` crossing per instance (work-item 228) | [`HpThresholdPayload`] | -//! | `on_go_used` | `gameobject::apply_use_gameobject` success exit — player use AND the debug drivers (work-item 228) | [`GoUsedPayload`] | +//! | `on_creature_death` | `combat::kill_creature`, non-pet branch, the encounter-grade twin of `on_death` (entry + instance snapshot) | [`CreatureDeathPayload`] | +//! | `on_hp_threshold` | `encounter::encounter_hp_probe`, the kernel's own `on_damage_taken` handler; fires once per registered `(entry, pct)` crossing per instance | [`HpThresholdPayload`] | +//! | `on_go_used` | `gameobject::apply_use_gameobject` success exit, player use AND the debug drivers | [`GoUsedPayload`] | //! //! Extending the catalog = the payload struct here + the `HOOK_EVENTS` row in `module/build.rs` //! (which generates the `payload_for` alias, the `fire_*` fn, and the registry array from that @@ -197,7 +197,7 @@ pub struct GossipSelectPayload { pub option_row_id: u32, } -/// A CREATURE died for real (work-item 228, the encounter kernel's death event) — fired from +/// A CREATURE died for real (the encounter kernel's death event), fired from /// `combat::kill_creature`'s non-pet branch right after `on_death`, with the encounter-grade /// identity `on_death` lacks: the victim's `entry` and `instance_id`, SNAPSHOTTED before the corpse /// row was mutated. Pet clean-despawns and player deaths do NOT fire this (encounters key on wild @@ -221,16 +221,6 @@ pub struct CreatureDeathPayload { pub threat_snapshot: Vec<(u64, i64)>, } -/// A watched creature crossed a registered HP threshold (work-item 228): its post-damage health -/// reached `pct`% or below for the FIRST time in this instance. Fired by the encounter kernel's -/// damage probe (`encounter::encounter_hp_probe`, itself an `on_damage_taken` handler), once per -/// `(entry, pct)` watch per instance — a heal back above and a re-drop never re-fires until -/// `encounter::reset_hp_fired`/the instance sweep clears the fired-mark. Register watches via -/// `encounter::watch_hp_threshold(ctx, entry, pct)`; with no registered `on_hp_threshold` handler -/// anywhere in the build the probe is a single constant branch per damage event (zero-cost path). -/// A LETHAL blow never reaches the probe (every kill path returns before the damage hook), so -/// thresholds skipped by the killing hit do NOT fire — key final-phase/execute logic on -/// `on_creature_death`, never on a low-pct watch (review note; a 1% watch is unreliable by design). pub struct HpThresholdPayload { pub creature_guid: u64, pub entry: u32, @@ -238,7 +228,7 @@ pub struct HpThresholdPayload { pub pct: u8, } -/// A unit successfully USED a gameobject (work-item 228) — fired at `apply_use_gameobject`'s +/// A unit successfully USED a gameobject, fired at `apply_use_gameobject`'s /// success exit (player `use_gameobject` and the debug drivers share that core), AFTER the /// type-dispatch committed its effect (chest looted, door toggled, goober credited, inert no-op). /// Rejected uses (out of range, already-looted chest...) never fire. `instance_id` is the GO diff --git a/module/src/import_meta.rs b/module/src/import_meta.rs index 4855e98de..4257e9cbd 100644 --- a/module/src/import_meta.rs +++ b/module/src/import_meta.rs @@ -1,4 +1,4 @@ -//! Import provenance stamps (work-item 216). `game_import_meta` records, per FAMILY, the source +//! Import provenance stamps. `game_import_meta` records, per FAMILY, the source //! SHA / file hash / row count / timestamp of the last successful (re)load, so a live DB can answer //! "what data am I running?" via `spacetime sql` — a question neither `import-world.sh`'s floor //! assertions nor the wire suite's data gates (docs/danger-zones.md) can answer on their own (they diff --git a/module/src/instance.rs b/module/src/instance.rs index 743cde040..123bdd174 100644 --- a/module/src/instance.rs +++ b/module/src/instance.rs @@ -1,76 +1,4 @@ -//! Dungeon-instancing lifecycle (work-item 190 slices 2+3). Slice 1 landed the substrate — an -//! `instance_id` column on `game_world_entity` (+ `by_grid`), every entity↔entity gate, and the -//! gateway relay gates. This module adds the LIFECYCLE: -//! -//! - **`game_instance`** — one row per live instance (map, owning party, occupancy stamp, reset -//! flag). `instance_id` is the `#[auto_inc]` PK: SpacetimeDB allocates from 1, so **0 = the open -//! world** is reserved by construction, never by convention-checking code. -//! - **`game_instance_binding`** — one row per (character, map): which instance that character -//! re-enters through the dungeon portal. Survives party disband (vanilla: you stay bound to the -//! instance you entered until it resets/reaps); dropped when the instance is reaped. -//! - **Entry** — `resolve_or_create_instance`, called from the 225 areatrigger hook -//! (`quest::apply_enter_areatrigger`) when the portal targets a DUNGEON map. Resolve order: **the -//! party's live instance → the character's own live binding → create** (reversed the -//! first two — the 190 design's binding-first order split a party whose members had entered -//! separately). Solo entry allowed (binds to the character, `party_id = 0`), and a solo instance -//! is ADOPTED by the party its holder has since joined so the members behind them join it too. -//! - **`create_instance`** — inserts the row, spawns the per-instance POPULATION (every -//! `game_creature_spawn` template on the map through the NORMAL `build_creature_entity` path with -//! the new instance id — templates/spawn rows are NEVER cloned), per-instance COPIES of the -//! dungeon's interactive gameobjects (DOOR/BUTTON/CHEST/GOOBER, type-gated) — unless -//! `game_config.hosts_instances` is off, in which case it files the row alone as a LEASE and the -//! shard that owns the map spawns the population via `ensure_instance`. It does NOT arm a -//! dedicated 229 tick row — the catch-all covers every instance at the same cadence for free (perf -//! catalog 1.3); `debug_arm_instance_tick` still arms one on demand for a faster cadence. -//! - **Reap (slice 3)** — its own scheduled reducer (`reap_instances`, the `EventReaperSchedule` -//! precedent — gc.rs is deliberately untouched): reaps an instance empty for -//! [`INSTANCE_EMPTY_REAP_MICROS`] (30min) or flagged `reset_requested` while empty. Teardown -//! order per the design: population (entities/corpses/loot/GO copies) → the 228 encounter-kernel -//! sweep splice → the 229 tick row → bindings → the `game_instance` row. -//! -//! ## Occupancy mechanism (slice 3 decision — stated per the work item) -//! `last_empty_at_micros` is maintained by the REAPER itself, not by the per-instance tick row's -//! sense tick: each `reap_instances` firing makes ONE pass over `game_world_entity` classifying -//! every instance's player-occupancy at once (a `HashSet` of instance ids with a live player), then -//! stamps/clears/reaps per instance. Cost: one entity-table scan per reaper firing (60s), ZERO -//! added work on the hot 0.5s creature tick, and a single writer for the stamp (no tick↔reaper -//! write race). Minute resolution is exact enough for a 30-minute threshold — reap eligibility -//! requires two reaper observations ≥30min apart to BOTH see the instance empty, and any re-entry -//! in between clears the stamp. The 229 sense-tick alternative would add a per-4s write path per -//! instance for no accuracy the 30-minute constant can use. `reset_requested` takes effect on the -//! next firing that observes the instance empty (≤60s latency — acceptable; vanilla's reset is -//! instant but our async reap is invisible to the resetting party, who by definition are outside). -//! -//! ## Respawn-within-a-run (v1 decision — stated per the work item) -//! Instance populations are **entity-only** (the 229 trap: `game_creature_spawn` rows are NOT -//! instance-tagged, and `pass_respawn` builds at instance 0 — riding it would leak respawns into -//! the open world). So trash killed inside a live run does NOT respawn (vanilla 5-mans do respawn -//! trash on a timer; honest v1 gap — per-instance spawn bookkeeping is the future fix), instance -//! corpses do NOT decay mid-run (`pass_decay` is spawn-row-driven; corpses + their loot live until -//! the reap — cosmetically fine for a bounded-lifetime instance, and loot stays takable), and the -//! population is stationary-until-aggro (no waypoint/wander/return-home passes: all three anchor on -//! spawn rows/waypoints keyed by the spawn guid, which per-instance copies deliberately don't -//! share). Aggro/assist/chase/flee/casting all work — they are entity+template+melee-row driven. -//! -//! ## Guid namespaces (collision-proofing, unit-tested) -//! Per-instance creature copies reuse the wave-guid layout (`encounter::wave_guid`: -//! `0xF130 | entry<<24 | low24`) with **bit 23 of the low set** ([`INSTANCE_POP_LOW_BAND`]) — a -//! band disjoint from imported spawn lows (cmangos db guids ≪ 2^23) and from `spawn_wave`'s -//! allocator (which maxes over SPAWN rows only and so must never be able to collide with these -//! spawn-row-less entities). Per-instance GO copies get `0xF110 | bit46 | seq` -//! ([`GO_COPY_BAND`]) — below `gameobject::POOL_TAG` (bit 47), above every static/debug low. -//! -//! ## Instance Removal -//! A Character that stands in a Group's dungeon instance without being a member of that Group gets -//! a 60-second countdown, then goes to its hearthstone home (cm:Player.cpp:17694-17731). The rule -//! is state, not an edge: [`reconcile_instance_removal`] runs wherever membership or location -//! changes on this Shard (the Group mirror, the membership cores, login), and the one-shot -//! `game_instance_removal` row fires [`expire_instance_removal`], which reads the rule again -//! before it moves anybody. -//! -//! Every table here is private. The Coordinator subscribes to `game_instance` with the Owner Token -//! so Transfer can read the source lease's admitted map and party, and to `game_instance_removal` -//! to relay the countdown. `game_instance_binding` remains unsubscribed. [server] +//! Instance lifecycle. See `docs/instance-lifecycle.md` for the contract and rationale. use std::collections::{HashMap, HashSet}; @@ -103,25 +31,12 @@ pub(crate) use lyracore_shared::instance::is_dungeon_map; #[cfg(test)] pub(crate) use lyracore_shared::instance::DUNGEON_MAPS as SHARED_DUNGEON_MAP_IDS; -/// One fully-described dungeon. This used to be FOUR hand-synchronized `match map_id` -/// sites — [`entrance_fallback`] here, plus `world::graveyard::instance_release_zone` and -/// `...instance_static_fallback` — each of which could independently gain (or omit) an arm for a -/// map, and the omission surfaced only as a runtime `warn!` the first time a release actually hit -/// the gap (`resolve_graveyard`, earlier). A `DungeonMap` has no optional fields, so a map is now -/// either fully described here — entrance AND release zone AND release fallback, together — or -/// absent, i.e. not a dungeon; there is no half-configured state left to warn about. pub(crate) struct DungeonMap { pub(crate) map_id: u32, /// The open-world position OUTSIDE the entrance — `(map, x, y, z, o)`, the stranding fallback /// for a login whose `pending_instance_id` was reaped ("fall back to the entrance at instance /// 0", design doc §3). /// - /// PREMISE CORRECTION (vs. the work item's "reverse-lookup the areatrigger pair" option): the - /// reverse-lookup is NOT implementable with current data — `game_areatrigger_teleport` rows - /// carry only the TARGET (the entrance row's target is INSIDE the dungeon; the exit row's - /// target is the coords we want but the row records neither its source map nor which dungeon it - /// exits, so with a second dungeon imported the lookup is ambiguous). Hence the per-map record, - /// `[V]`. pub(crate) entrance: (u32, f32, f32, f32, f32), /// The instance's own zone id for graveyard release (cmangos `game_graveyard_zone.ghost_zone`) /// — the map→zone hop `world::graveyard::resolve_zone_id` can't make without terrain. @@ -177,7 +92,7 @@ pub(crate) const INSTANCE_REAPER_INTERVAL_MICROS: i64 = 60 * 1_000_000; /// How long a **LEASE** must read empty before the reaper takes it. /// /// A lease is a `game_instance` row on a database that does not host instance populations -/// (`game_config.hosts_instances = false`) — the world shard of a Phase A deployment. +/// (`game_config.hosts_instances = false`). /// It reads EMPTY within seconds of the last party member transferring to the instance shard, i.e. /// seconds after the run *starts*, because occupancy is counted from live player entities and there /// are none here any more. Reaping it on the ordinary 30-minute timer therefore deletes the world @@ -204,7 +119,7 @@ pub(crate) const INSTANCE_REAPER_INTERVAL_MICROS: i64 = 60 * 1_000_000; /// hole. Upgrade path is the one [`teardown_instance_inner`] already names: realm-core owns the /// instance→shard index and the lease stops existing. **Single-database realms never reach /// this arm at all** — `hosts_instances` defaults to true, so their reap semantics are byte-for-byte -/// what they were (AC#4). +/// what they were (AC). pub(crate) const INSTANCE_LEASE_REAP_MICROS: i64 = 12 * 60 * 60 * 1_000_000; /// The empty-timer this database reaps instances on: [`INSTANCE_EMPTY_REAP_MICROS`] where the @@ -235,13 +150,6 @@ pub(crate) const INSTANCE_POP_LOW_BAND: u64 = 0x80_0000; /// entry-derived lows (< 2^24). pub(crate) const GO_COPY_BAND: u64 = 1 << 46; -/// The gameobject types that get per-instance copies at `create_instance` (type-gated per the -/// design: the dungeon's interactive props — doors, levers, chests, quest objects). GATHER nodes -/// and pool points deliberately do NOT copy: the pool/respawn machinery is spawn-row/point-table -/// driven and open-world-only. QUESTGIVER-type GOs are deliberately NOT in this list either (190 -/// review LOW): a static instance-0 QUESTGIVER on a dungeon map is UNREACHABLE from inside a run -/// (the use/giver gates require instance equality) — no such GO exists in today's content; if a -/// dungeon ever needs one, add the type here so it copies like the other interactive props. const GO_COPY_TYPES: [u8; 4] = [ crate::gameobject::go_type::DOOR, crate::gameobject::go_type::BUTTON, @@ -310,11 +218,6 @@ crate::character_owned!(transfer, fn sweep_transfer_game_instance_binding(ctx, c remint = id, }); -/// Drives the instance reaper (slice 3) — its OWN scheduled table per the `EventReaperSchedule` -/// precedent (gc.rs untouched by design). Seeded by `seed::init` at 60s; a live (auto-migrated) -/// node re-arms via `debug_repair_after_publish` (formerly the standalone -/// `debug_rearm_instance_reaper`) (init does not re-run on a plain publish — -/// danger-zones "init only on fresh publish" rule). [server] #[table(accessor = game_instance_reaper_schedule, scheduled(reap_instances))] pub struct InstanceReaperSchedule { #[primary_key] @@ -369,13 +272,6 @@ pub(crate) fn route_instance( party_live: Option, ) -> InstanceRoute { match (own_binding_live, party_live) { - // THE PARTY OUTRANKS THE PERSONAL BINDING (defect 3). This used to be the other - // way round, which SPLIT a party: a member who had already entered solo carried a binding - // to their own `party_id = 0` instance, that binding won, and the rest of the party — who - // could not see a solo instance through `by_party` — created a second dungeon. Vanilla's - // rule is the one here: a non-saved 5-man takes the GROUP's instance, and the personal - // binding is what you fall back to when you have no group (or your group has not entered - // yet). It still survives disband, because a party-less character reaches the `Own` arm. (_, Some(id)) => InstanceRoute::Party(id), (Some(id), None) => InstanceRoute::Own(id), (None, None) => InstanceRoute::Create, @@ -432,7 +328,7 @@ pub(crate) fn occupancy_action( } } -/// Where a login whose `pending_instance_id` was reaped lands (NEVER strand — design trap #3). +/// Where a login whose `pending_instance_id` was reaped lands (NEVER strand, design trap). /// Pure over the two facts the caller derives ([`entrance_fallback`], [`is_dungeon_map`]); /// alive-or-ghost is orthogonal and preserved per 226's `pending_ghost` rules at the call site. #[derive(Debug, PartialEq)] @@ -721,14 +617,8 @@ pub(crate) fn create_instance_with_id( }); let instance_id = inst.instance_id; - // --- THE HOSTING GATE (defect 1). ------------------------------------------------ - // A database that does not host instance populations files the row + (the caller's) binding and - // spawns NOTHING: a LEASE, the exact shape `teardown_instance_inner(delete_row = false)` leaves - // behind after a cross-database eviction. On the open-world shard of a Phase A deployment this - // is the whole fix for "the portal spawned 207 creatures + 28 GO copies on the writer that is - // not going to run the dungeon" — the population is spawned on the shard that owns the map, - // when the gateway mirrors this id there via `ensure_instance`, which runs on a database where - // this flag is (and stays) true. Default true ⇒ a single-database realm is unchanged. + // A non-hosting database files the lease and binding without population. + // The owning Shard creates population when ensure_instance receives this lease. if !hosts_instance_populations(ctx) { log::info!( "create_instance: instance {instance_id} map {map_id} party {party_id} — LEASE only \ @@ -756,12 +646,7 @@ pub(crate) fn create_instance_with_id( } } } - // Tracked wave/summon spawn rows are NOT population (190 review HIGH): 227's spawn_wave - // inserts REAL untagged game_creature_spawn rows on the dungeon map (Sneed, VanCleef adds) - // that persist until THEIR instance's reset/sweep — without this exclusion, a fresh instance - // created while another run is live (or recently dead) would spawn a pre-summoned Sneed - // standing on the wreck and duplicate mid-fight adds. Every wave row is tracked in - // game_encounter_spawn by definition — that table IS the wave registry. + let tracked_wave_guids: std::collections::HashSet = ctx .db .game_encounter_spawn() @@ -857,18 +742,6 @@ pub(crate) fn create_instance_with_id( copied += 1; } - // --- NO dedicated per-instance tick row (perf catalog 1.3). This used to unconditionally insert - // one at INSTANCE_TICK_INTERVAL_MICROS — the SAME cadence as the global catch-all, so it bought - // zero latency smoothing while each firing paid the FIXED per-firing costs the partition does not - // divide: a transaction commit on the serialized writer, a TickScope rebuild, active_cell_radius, - // active_cell_creatures' player scan, and the pet phase's candidate list on sense ticks. With M live - // instances that is M extra tx/s each carrying an O(E) scan, for creature populations that are - // stationary-until-aggro. The catch-all covers every instance with no dedicated row - // (`TickScope::from_rows`) — the exact coverage teardown already relies on (step 5 below deletes - // the row and lets the catch-all take over), so instance creatures tick identically, just inside - // the catch-all's transaction. An operator who wants a genuinely FASTER cadence for one instance - // still arms one explicitly via `debug_arm_instance_tick`. - log::info!( "create_instance: instance {instance_id} map {map_id} party {party_id} — {spawned} creatures, {copied} GO copies" ); @@ -938,7 +811,7 @@ pub fn ensure_instance( /// **Source side.** Evict an instance's POPULATION from this database, keeping the `game_instance` /// row and its bindings as a lease (see [`teardown_instance_inner`]). /// -/// This is what makes AC#2 true: the world shard spawned the dungeon when the first +/// This is what makes AC true: the world shard spawned the dungeon when the first /// player stepped through the portal, but the run happens on the instance shard, so the world /// writer must stop ticking its creatures. Refuses while a live player is inside — the same guard /// `teardown_instance` has, and here it is load-bearing: a member who has NOT transferred yet is a @@ -974,13 +847,8 @@ pub fn evict_instance_population( /// player-occupancy, then each instance stamps/clears/waits/reaps per [`occupancy_action`]. /// Teardown is batched ([`REAP_MAX_PER_FIRING`]); leftovers reap next firing (60s later). /// -/// **Reaper LOCALITY (AC#1).** Every database runs its own reaper over its own -/// `game_instance` rows and nothing else — there is no cross-database sweep and no shard id -/// anywhere in it. A database that owns no instances therefore does no work at all: the row scan -/// below finds nothing and returns *before* the O(entities) occupancy pass, so an open-world writer -/// in a Phase A deployment pays for the reaper only while it is actually holding leases, and a -/// pool member pays only for the runs it hosts. The empty timer itself is per-database too — see -/// [`empty_reap_micros`]. +/// Each database reaps only its hosted Instances. With no leases, it skips the entity scan. +/// Empty timers are database-local; see [`empty_reap_micros`]. #[reducer] pub fn reap_instances(ctx: &ReducerContext, _schedule: InstanceReaperSchedule) { if ctx.sender() != ctx.database_identity() { @@ -1413,14 +1281,6 @@ mod tests { ); } - /// The cross-tier tripwire: [`lyracore_shared::instance::DUNGEON_MAPS`] is the id - /// list `gateway::config::ShardMap::check_instance_hosting` walks — every id in it - /// MUST have a full record in this crate's [`DUNGEON_MAPS`], or a login/release on that map - /// would silently take the "not a dungeon" path every helper here takes for an absent - /// `DungeonMap`. This is the test-time replacement for the runtime `warn!` `resolve_graveyard` - /// used to log the first time a release actually hit a half-configured map (deleted — a - /// `DungeonMap` has no optional fields, so once an id clears this pin it can never regress into - /// a half-configured state). #[test] fn every_shared_dungeon_map_has_a_dungeon_maps_record() { for &m in SHARED_DUNGEON_MAP_IDS { @@ -1435,7 +1295,7 @@ mod tests { is_dungeon_map(d.map_id), "map {} has a module::instance::DUNGEON_MAPS record but is missing from \ lyracore_shared::instance::DUNGEON_MAPS — its instance-hosting would never be \ - checked at gateway startup (issue #48)", + checked at Gateway startup", d.map_id ); } @@ -1512,7 +1372,7 @@ mod tests { ); } - /// **AC#4** — reap semantics on a shard-pool deployment, stated against the single-database + /// **AC**, reap semantics on a shard-pool deployment, stated against the single-database /// ones they must not change. #[test] fn a_lease_only_database_holds_its_stub_rows_far_past_the_thirty_minute_run_timer() { @@ -1546,19 +1406,13 @@ mod tests { "with the lease timer the world shard still knows which instance that party is in" ); - // The MAGNITUDE, not just the ordering. Adversarial review: the assertions above pass with - // any lease timer over ~40 minutes (45min was verified green), and a lease's countdown - // starts at the run's START — it is not an idle grace period, it is a bound on RUN LENGTH. - // A vanilla BRD or Stratholme clear runs 3–5 hours and a raid night runs longer, so a 3h - // lease re-creates the exact 30-minute fork for the longest runs. Pin the requirement to - // the run length the realm intends to support, so shrinking the constant is what fails. let longest_supported_run = 8 * 60 * 60 * 1_000_000i64; assert!( lease > longest_supported_run, "the lease timer ({lease}µs) must outlast the LONGEST run it is a receipt for, not \ merely the 30-minute hosting timer: it starts counting when the party leaves the world \ shard, i.e. at minute ~0 of the run. Below this bound a long dungeon or raid forks its \ - own party exactly as the 30-minute timer did — the bug #21 set out to fix." + own party exactly as the 30-minute timer did." ); assert_eq!( occupancy_action(false, now - longest_supported_run, false, now, lease), diff --git a/module/src/items/economy.rs b/module/src/items/economy.rs index 3df687198..dedbe460d 100644 --- a/module/src/items/economy.rs +++ b/module/src/items/economy.rs @@ -20,13 +20,6 @@ use super::tables::{ /// `CMSG_BUY_ITEM`, so this only rejects clearly-out-of-range abuse (mirrors `loot::LOOT_RANGE_SQ`). const VENDOR_RANGE_SQ: f32 = 100.0; -/// The shared trust-boundary gate for a player-initiated NPC interaction — vendor sell/buyback/buy and -/// armorer repair all reduce to the SAME five checks: the player must be alive, the target must be a -/// real NPC (never another player, `is_player()`) carrying `required_flag`, on the player's own -/// map+instance, within `VENDOR_RANGE_SQ`. Extracted ("the exact place drift happens" — the -/// 190 review already caught one copy of this shape missing the instance check once) from -/// `apply_item_sell` / `apply_buyback_item` / `apply_buy_item` / `apply_player_repair`, which used to -/// paste this ~20-line block four times. /// /// The four call sites' WIRE-VISIBLE error text differs in wording, not in the checks themselves, so /// three of the five messages stay parameterized rather than folded into one template: diff --git a/module/src/items/inventory.rs b/module/src/items/inventory.rs index 9ef732c39..cf696f075 100644 --- a/module/src/items/inventory.rs +++ b/module/src/items/inventory.rs @@ -22,17 +22,6 @@ use crate::{game_player_reputation, game_player_skill}; // Live only under `debug_reducers`: `debug_split_item` is its sole caller. #[cfg_attr(not(feature = "debug_reducers"), allow(dead_code))] -/// Stack-split core: split `count` units off the stack in `slot` into the empty `to_slot`, leaving -/// the remainder in the source. Vanilla only splits a STRICT subset -/// (you can't split off the whole stack — that's a move), so `count == 0` or `count >= stack_count` is -/// rejected; the destination must be empty AND outside the equipment region -/// (`valid_split_dest_slot`) — a split can never legitimately land on the body, and unlike -/// `apply_item_move` this path runs no equip-validation at all, so admitting 0..=18 here bypassed -/// `can_equip_into`/proficiency/required-level/BoE entirely. The new partial-stack row reuses the -/// source's entry / owner / durability and takes a fresh GUID and the -/// current timestamp. Errors if the source slot is empty, the count is invalid, the destination is -/// an equipment slot, or the destination is occupied. Additive — decrements the source row and -/// inserts one new item row. [entity] pub(crate) fn apply_item_split( ctx: &ReducerContext, player_guid: u64, @@ -287,7 +276,7 @@ pub(crate) fn apply_item_move( } src.slot = to_slot; instances.guid().update(src); - // Parity #8: if either endpoint is an EQUIPMENT slot (0..=18), gear just changed on the body, so + // Parity: if either endpoint is an EQUIPMENT slot (0..=18), gear just changed on the body, so // re-derive the owner's max HP/mana (recompute_vitals now folds equipped Stamina/Intellect). The // health bar grows when you equip a +Sta piece and shrinks when you take it off. A pure bag↔bag move // touches no equip slot → skipped, so loose-inventory shuffles are byte-identical. (recompute_vitals @@ -581,10 +570,6 @@ fn bank_access(ctx: &ReducerContext, player_guid: u64) -> Result<(), ItemRefusal // Live only under `debug_reducers`: `apply_item_split` is its sole caller. #[cfg_attr(not(feature = "debug_reducers"), allow(dead_code))] -/// A split must leave at least one unit in BOTH the source and the new stack — splitting off none -/// (`count == 0`) or the whole stack (`count >= stack_count`, that's a move) is rejected. Extracted from -/// `apply_item_split` (pure code-motion) so the count-gate boundaries are unit-tested without a live -/// module. pub(crate) fn valid_split_count(count: u32, stack_count: u32) -> bool { count != 0 && count < stack_count } @@ -773,8 +758,6 @@ mod tests { } } - /// SPLIT COUNT GATE: `count == 0` and `count == stack_count` (splitting off nothing, or the whole - /// stack — that's a move) are rejected; every count strictly between 0 and the stack passes. #[test] fn valid_split_count_rejects_zero_and_the_whole_stack_only() { const STACK: u32 = 5; diff --git a/module/src/items/mod.rs b/module/src/items/mod.rs index 397270b73..417d0399b 100644 --- a/module/src/items/mod.rs +++ b/module/src/items/mod.rs @@ -9,15 +9,6 @@ //! would hide it). See [[gtker-descriptor-setter-wall]]: the inventory slot is reachable via gtker's //! typed `set_player_field_inv(ItemSlot, Guid)` (NOT walled like the aura array), so this needs no raw //! encoder. -//! -//! This file is a thin facade over five by-concern submodules — `tables` (the two table structs + RLS -//! filter + guid/slot primitives), `rules` (the pure equip/economy taxonomy + arithmetic + their unit -//! tests), `ops` (the grant/use/loot mutation cores), `economy` (the vendor sell/buy/buyback/repair -//! cores + their shared NPC gate), and `inventory` (the move/split/equip/unequip cores + slot-space -//! vocabulary). The `pub use ...::*` re-exports keep every symbol reachable as -//! `crate::items::` regardless of which submodule defines it. The sender-path `#[reducer]` entry -//! points that used to live in a `reducers` submodule here are gone — the gateway's `gw_*` -//! reducers (`gw.rs`) are the only surviving callers of these cores. mod economy; mod exchange; diff --git a/module/src/items/ops.rs b/module/src/items/ops.rs index 12a5d9270..71faf1187 100644 --- a/module/src/items/ops.rs +++ b/module/src/items/ops.rs @@ -86,14 +86,7 @@ pub(crate) fn grant_starter_item( starter_item::HEARTHSTONE_SLOT, 1, )?; - // Per-class loadout from CharStartOutfit (game_start_item, importer --dbc): EQUIP each equippable - // piece into its resolved slot (so weapons/armor render on the model) and stow the rest in the - // backpack — so a Mage spawns with a staff/robe, not the Warrior's sword. Keyed by the character's - // (race, class) (unit_bytes_0). Falls back to the hand-authored Warrior loadout below when the outfit - // table is empty (pre-import) — login never breaks. - // (race, class) from the DURABLE character row — NOT the live world entity: the creation-time - // call site runs before any entity exists (that's the whole point — gear must show on the - // char-select screen, which renders from item rows alone). + let race_class: u16 = ctx .db .game_character() @@ -435,7 +428,7 @@ pub(crate) fn grant_letter_item( } /// Total quantity of `item_entry` the player owns across ALL stacks (bag + equipped) — drives -/// collect-quest completion (parity #4). Sums `stack_count` over the owner's matching item rows. [entity] +/// collect-quest completion (parity). Sums `stack_count` over the owner's matching item rows. [entity] pub(crate) fn item_count(ctx: &ReducerContext, owner_guid: u64, item_entry: u32) -> u32 { ctx.db .game_item_instance() @@ -508,14 +501,6 @@ pub(crate) fn restore_carried_items( /// Reusing the existing 11196 row avoids seeding a new cooldown spell entirely. pub(crate) const RECENTLY_BANDAGED_SPELL: u32 = 11196; -/// The on-use spell for `tmpl`, or `None` if it carries no on-use effect. `spellid_1`/`spelltrigger_1` -/// (`ItemTemplate`, shipped to the client for tooltips) are the SINGLE on-use authority (finishing -/// the migration `rules.rs` used to name as the end state): a nonzero `spellid_1` whose trigger slot is -/// `ItemSpellTriggerType::OnUse` (0) IS the item's on-use cast. This retires the old hardcoded -/// `USE_EFFECTS` shadow-map (a duplicate of exactly this column) — the importer/seed data populate the -/// column directly, so a newly-imported consumable works with zero code, and `apply_item_use` reads -/// this ONE thing instead of two competing mechanisms. Pure — unit-tested. The `begin_cast` core then -/// routes the spell instant/channel/buff off its OWN header (no per-item code). fn use_spell_for(tmpl: &ItemTemplate) -> Option { (tmpl.spellid_1 != 0 && tmpl.spelltrigger_1 == 0).then_some(tmpl.spellid_1) } @@ -880,11 +865,7 @@ pub(crate) fn request_take_loot( "loot on another map", )); } - // Instance gate (190 slice 2 review HIGH): instances overlay IDENTICAL coordinates, so the - // range gate below is routinely satisfiable across the instance wall — party B's looter in - // their own Deadmines stands within 10yd of party A's corpse/chest position. This was the ONE - // loot path with no slice-1 DEFERRED marker, so the gate sweep missed it: the module is the - // authority regardless of what any client shows. + if src_instance != player.instance_id { return Err(ActionRefusal::new( ActionRefusalKind::OtherPartition, @@ -910,7 +891,7 @@ pub(crate) fn request_take_loot( .ok_or_else(|| { ActionRefusal::new(ActionRefusalKind::MissingTarget, "no loot in that slot") })?; - // Quest-only rows (work-item 187 slice 0): the TAKER's OWN need is re-validated server-side (the + // Quest-only rows : the TAKER's OWN need is re-validated server-side (the // gateway's window is a display hint, not authoritative) — an unreserved row (`reserved_for == 0`, // the shared row nobody has split yet) is claimable by anyone who currently needs it; an already // per-member-reserved clone is claimable ONLY by its reserved owner. A non-quest row (the common @@ -934,7 +915,7 @@ pub(crate) fn request_take_loot( row.designated_looter_guid, player_guid, ) { - // Group loot methods (work-item 187 slices 2-4): a NEED/GREED winner-locked row + // Group loot methods : a NEED/GREED winner-locked row // (`reserved_for`), a MASTER-only row, or a round-robin/below-threshold row designated to // someone else all reject the plain autostore path here — server-authoritative, the gateway's // per-viewer loot-window filter (`reads.rs`) is a display hint only. diff --git a/module/src/items/rules.rs b/module/src/items/rules.rs index 078c96b77..2c92adcd7 100644 --- a/module/src/items/rules.rs +++ b/module/src/items/rules.rs @@ -100,9 +100,6 @@ pub fn binds_on_grant(item_bonding: u8) -> bool { item_bonding == bonding::BIND_ON_PICKUP } -/// Does a template with this `bonding` value bind the FIRST time it's equipped? Pure — unit-tested. -/// Only BIND_ON_EQUIP (BoE) does; a BoP item is already bound by `binds_on_grant` before it can ever -/// be equipped, and every other value never binds on equip in this slice. [reference] pub fn binds_on_equip(item_bonding: u8) -> bool { item_bonding == bonding::BIND_ON_EQUIP } @@ -135,8 +132,6 @@ pub mod equip_slot { pub const END: u8 = TABARD; } -/// The vanilla `INVTYPE_*` codes (`item_template.inventory_type`) this slice maps to equipment slots. -/// A small named subset of the full enum — the ones representing wearable/wieldable gear. [reference] pub mod invtype { pub const NON_EQUIP: u8 = 0; pub const HEAD: u8 = 1; @@ -718,12 +713,6 @@ pub(crate) mod tests { assert_eq!(EquipStat::resistance_for_school(1 << 7), None); } - /// Re-scoped (was `equipped_stat_sum_adds_across_pieces_and_is_zero_for_no_gear`): the original name - /// oversold this as covering `equipped_stat_bonus`, but that fn needs a live `ReducerContext` (it - /// joins the owner's equipped `ItemInstance` rows through `game_item_template`, filters to the - /// equipment slot range, and excludes broken items — none of which this test touches). What's - /// actually pinned here is only the pure per-stat SUM `template_stat` feeds into that fold: summing - /// `template_stat` over a worn set adds per-stat independently and is 0 for an empty/all-zero set. #[test] fn template_stat_sums_independently_per_stat_and_is_zero_for_no_gear() { let sum = |pieces: &[ItemTemplate], which: EquipStat| -> i32 { diff --git a/module/src/items/tables.rs b/module/src/items/tables.rs index 6ace74a2d..c537beb7e 100644 --- a/module/src/items/tables.rs +++ b/module/src/items/tables.rs @@ -35,16 +35,7 @@ pub struct ItemTemplate { pub damage_min: f32, // physical melee damage range (tooltip) pub damage_max: f32, pub delay_ms: u32, // weapon swing speed (ms) - // --- equip stat bonuses (END-appended, #[default(0)] → additive auto-migration) --- - // Vanilla items carry up to 10 (stat_type, stat_value) pairs plus an armor value; this slice flattens - // the handful that the EXISTING effective-* combat pipeline can fold into observable readouts into one - // typed column each (the union-killed form, like the spell module's typed `p0`/`amount`). A piece with no - // bonus leaves every column 0, so an existing template (e.g. the starter Worn Shortsword, entry 25) - // is byte-identical after migration and the player's swing readout is unchanged (baseline-safe). The - // five base attributes match the `UNIT_FIELD_STAT` order; `stat_crit`/`stat_hit` are rating points - // (basis-point-ish, folded straight into the attack-table crit/miss bands); `stat_armor` adds on TOP - // of the agility*2 base armor (the combat module ADDS, never double-counts). i32 so a (future) negative - // suffix-enchant fits; today every seeded value is ≥ 0. [reference] + #[default(0)] pub stat_strength: i32, #[default(0)] @@ -106,7 +97,7 @@ pub struct ItemTemplate { /// Item binding: the cmangos `item_template.bonding` value — 0 = NoBind, 1 = Bind on Pickup (BoP), /// 2 = Bind on Equip (BoE), 3 = Bind on Use, 4/5 = Quest Item (see [`super::rules::bonding`]). /// Drives the client's "Binds when picked up/equipped" tooltip line via - /// `SMSG_ITEM_QUERY_SINGLE_RESPONSE.bonding`. Trade and mail enforcement (work-items 067, 068) are + /// `SMSG_ITEM_QUERY_SINGLE_RESPONSE.bonding`. Trade and mail enforcement (068) are /// out of scope here — this is the data model only. /// END-appended + `#[default(0)]` → additive auto-migration (every existing template reads /// NoBind, baseline-safe). [reference] @@ -114,7 +105,7 @@ pub struct ItemTemplate { pub bonding: u8, /// The 6 vanilla resistance schools (cmangos `item_template.holyres/fireres/natureres/frostres/ /// shadowres/arcaneres`) — resist gear is dead weight until these land; the effective-resistance - /// combat math already exists and can fold these in once a consumer reads them. Work-item 213: + /// combat math already exists and can fold these in once a consumer reads them.: /// DATA PLUMBING ONLY — no consumer reads these yet, so every existing template stays at 0 /// (baseline-safe). i32 matching `stat_armor`'s sibling convention (a future negative /// suffix-enchant fits). END-appended + `#[default(0)]` → additive auto-migration. [reference] @@ -132,7 +123,7 @@ pub struct ItemTemplate { pub arcane_res: i32, /// On-use/on-equip spell slots 3-5 (vanilla items carry up to 5; slots 1-2 already cover every /// 1-10 consumable). Completes the 191 proc engine's item half — no consumer reads these yet - /// (work-item 213: data plumbing only). Same shape as `spellid_1`/`spelltrigger_1`: `u32` id, + /// (data plumbing only). Same shape as `spellid_1`/`spelltrigger_1`: `u32` id, /// `u8` `ItemSpellTriggerType`. END-appended + `#[default(0)]` → additive auto-migration. /// [reference] #[default(0)] @@ -148,7 +139,7 @@ pub struct ItemTemplate { #[default(0)] pub spelltrigger_5: u8, /// Weapon-skill proficiency gate (cmangos `RequiredSkill`/`RequiredSkillRank`) — mail/plate - /// proficiency + weapon-skill requirements beyond the class tables. Work-item 213: data + /// proficiency + weapon-skill requirements beyond the class tables.: data /// plumbing only, no consumer reads these yet (0 = no skill gate, baseline-safe). /// END-appended + `#[default(0)]` → additive auto-migration. [reference] #[default(0)] @@ -156,7 +147,7 @@ pub struct ItemTemplate { #[default(0)] pub required_skill_rank: u32, /// Reputation gate (cmangos `RequiredReputationFaction`/`RequiredReputationRank`) — the 195 - /// item half. Work-item 213: data plumbing only, no consumer reads these yet (0 = no rep gate, + /// item half.: data plumbing only, no consumer reads these yet (0 = no rep gate, /// baseline-safe). END-appended + `#[default(0)]` → additive auto-migration. [reference] #[default(0)] pub required_reputation_faction: u32, @@ -214,7 +205,7 @@ pub struct ItemInstance { #[default(0)] pub enchant_id: u32, /// Item binding state: true once this SPECIFIC instance has bound to its owner and cannot be - /// traded or mailed away (enforcement lands via work-items 067/068 — this column is the data + /// traded or mailed away (enforcement lands via, this column is the data /// model they gate on). Set `true` at grant time for a BoP-templated item (quest reward, starter /// kit, loot, vendor buy — any `store_item`/`grant_starter_item` insert) and on first EQUIP for a /// BoE-templated item (`apply_item_move`'s equip-validation branch). A BoU/unbound-type template diff --git a/module/src/lib.rs b/module/src/lib.rs index 95eb8614b..7045c3611 100644 --- a/module/src/lib.rs +++ b/module/src/lib.rs @@ -40,7 +40,7 @@ /// params (ctx, guid, the new owning identity); expands to `pub(crate) fn (ctx: /// &ReducerContext, guid: u64, identity: Identity)`. /// -/// `character_owned!(transfer, fn (, , ) { table = .., by = .., .. })` and +/// `character_owned!(transfer, fn (, , ) { table =.., by =..,.. })` and /// `character_owned!(not_transported, fn ())` — the CROSS-DATABASE row transport, /// which is what lets a table's rows actually leave one SpacetimeDB database and arrive in /// another. Both expand to `pub(crate) fn (ctx: &ReducerContext, guid: u64, io: &mut @@ -54,17 +54,8 @@ /// /// # The transport GRAMMAR, and why it is a grammar /// -/// A transport arm used to be a free-form `$body:block`, so "does this arm actually transport, or -/// does it silently drop the table's rows?" was a question only a source SCANNER could answer — and -/// a scanner is an arms race it eventually loses. The review proved it: repointing -/// `sweep_transfer_game_item_instance` at `not_transported` deleted every character's gear on every -/// hop with 468 module tests green, and each hardening of the scan (`contains("move_rows")`, then -/// "exactly once", then "at the top", then "and it filters by the guid") was defeated by the next -/// dead-branch trick. -/// -/// So the arm has no body any more. It DECLARES the two facts the transport needs and the macro -/// writes the code, which makes every one of those mutations a parse error instead of a test the -/// scanner has to be clever enough to fail: +/// The transport arm declares its table, Character index, and surrogate key. The macro +/// generates export and import operations from those declarations. /// /// ```text /// // Transports. Exports the rows `` yields for the transferring guid, and re-inserts each @@ -226,7 +217,7 @@ macro_rules! game_tick_pass { /// Handlers OBSERVE and may act through the same reducer-internal fns core code uses; they must not /// assume any ordering relative to other handlers of the same event beyond the registry's /// deterministic (sorted-by-path) order. Mutating/decorator semantics (veto, fold) are explicitly -/// Phase 2 and NOT this mechanism. +/// and NOT this mechanism. #[macro_export] macro_rules! game_hook { ($event:ident, fn $name:ident($ctx:ident, $payload:ident) $body:block) => { @@ -281,7 +272,7 @@ macro_rules! encounter_package { include!(concat!(env!("OUT_DIR"), "/character_sweeps.rs")); // GENERATED by module/build.rs from packages/*/src/mod.rs discovery: one -// `#[path = ...] pub mod pkg_;` per drop-in package, compiling the package's Rust into this +// `#[path =...] pub mod pkg_;` per drop-in package, compiling the package's Rust into this // crate. Empty when packages/ holds no Rust packages. include!(concat!(env!("OUT_DIR"), "/package_mods.rs")); @@ -313,7 +304,7 @@ mod exploration; // feature, so they exist on production databases too; see `debug/mod.rs`. #[cfg(feature = "debug_reducers")] mod debug; -// Encounter kernel (work-item 228): instance-scoped encounter state, HP-threshold/death/GO-use +// Encounter kernel : instance-scoped encounter state, HP-threshold/death/GO-use // hooks, and the shared choreography primitives Packages consume (`packages/dungeons/` owns it). mod duel; mod encounter; @@ -327,7 +318,7 @@ mod go_collider; #[cfg(feature = "debug_reducers")] mod go_collider_fixture; mod go_model; -// Graveyard resolution (work-item 209/226): the death-release subsystem `world::do_repop` calls to +// Graveyard resolution : the death-release subsystem `world::do_repop` calls to // pick where a ghost teleports. Extracted from `world.rs`. mod graveyard; mod group; @@ -336,7 +327,7 @@ mod gw; mod helpers; pub mod hooks; mod import_meta; -// Dungeon-instancing lifecycle (work-item 190 slices 2+3): game_instance/game_instance_binding, +// Dungeon-instancing lifecycle : game_instance/game_instance_binding, // the areatrigger resolve-or-create entry, per-instance population spawn, and the reset/reap loop. mod combo; mod instance; @@ -399,7 +390,7 @@ mod package_teardown; #[cfg(test)] mod package_test; mod professions; -/// Deploy-safety tripwire: source-scans `scripts/**` + `tools/**` for a destructive +/// Publish-safety Architecture Tests scan `scripts/**` + `tools/**` for a destructive /// `spacetime publish -c`, and pins the sanctioned deploy script's own argv guard and required /// flags. Test-only; reads files, never runs the CLI. #[cfg(test)] @@ -438,10 +429,7 @@ mod transfer; /// the full-vmap epic). Builds on `nav`'s obstruction-grid approximation with an exact /// triangle store; see the module doc comment for the split. pub mod vmap; -// Source-scan tripwires (pulled these out of this file, which had grown to 1,146 lines — -// four fifths of it cfg(test) scan machinery — so the doc comment atop this file ("this is the thin -// index") stayed true). See `tripwires.rs`'s own module doc for the roster and the shared engine in -// `test_scan.rs` it now runs on. +// Architecture Tests for structural and publish invariants. #[cfg(test)] mod tripwires; /// Per-zone weather: the seasonal climate weights, the current-weather row the gateway relays, the diff --git a/module/src/load.rs b/module/src/load.rs index 0d7ca5e21..5f774c66f 100644 --- a/module/src/load.rs +++ b/module/src/load.rs @@ -16,9 +16,7 @@ //! history: enough to eyeball a sustained trend against a momentary spike without a single `ORDER //! BY` (`spacetime sql` has none — `docs/danger-zones.md` §2). //! -//! See `docs/region-sharding.md` for the two `spacetime sql` queries an operator runs against this -//! data, and `docs/danger-zones.md` §1.2 for why these are hand-authored gateway bindings rather -//! than a `spacetime generate` regen. +//! `docs/danger-zones.md` describes the Gateway binding generation rules. use spacetimedb::{reducer, table, ReducerContext, Table}; @@ -288,7 +286,7 @@ mod tests { assert_eq!( realm_wide_sessions(vec![(1, 10, 5), (2, 11, 7)].into_iter()), 12, - "distinct gateways' latest samples sum to the realm-wide total (issue #308)" + "distinct gateways' latest samples sum to the realm-wide total" ); // Ids need not arrive in order. assert_eq!( diff --git a/module/src/loot/mod.rs b/module/src/loot/mod.rs index fc02cf0a1..6a7e29e22 100644 --- a/module/src/loot/mod.rs +++ b/module/src/loot/mod.rs @@ -11,42 +11,13 @@ //! `crate::professions` (skinning/fishing/enchanting — feature reducers over that data, not loot-table //! machinery themselves). //! -//! Loot-family completeness (work-item 210) — two design decisions recorded here because they shape +//! Loot-family completeness, two design decisions recorded here because they shape //! every table/reducer below: -//! -//! 1. **FIXED 2026-07-07 (work-item 187 slice 0): quest-only drops roll UNCONDITIONALLY; visibility -//! and takability are decided PER VIEWER / PER TAKER, not at roll time.** cmangos marks a -//! quest-drop row with a negative `ChanceOrQuestChance`; the loot table still rolls ONCE per -//! corpse at the killing blow and the drop-CHANCE (`chance_bp`/`group_pick`) still applies exactly -//! as before — what changed is that the row is no longer gated on any player's quest state before -//! it rolls. Every rolled row now carries a `quest_only` marker (`CorpseLoot.quest_only`) plus a -//! `reserved_for` guid (`0` = shared/unclaimed by any specific character). Two consumers do the -//! ACTUAL gating now: the gateway's `corpse_loot` read (`gateway/src/stdb/reads.rs`) filters -//! quest_only rows PER VIEWER at loot-open time — a viewer sees an unreserved quest_only row only -//! if they currently need the item, and always sees a row already reserved for THEM; `apply_take_loot` -//! (`items/ops.rs`) re-validates the TAKER's own need server-side and, on the FIRST take of a -//! still-shared quest_only row, clones it once per OTHER group member who still needs the item -//! (each clone `reserved_for` that member, at a fresh slot) before deleting the shared original — -//! so every grouped quest-holder gets their own independently-lootable copy, matching vanilla, -//! instead of the item vanishing for everyone else the instant one member takes it. -//! ORIGINAL (pre-187) DECISION, superseded: quest-only drops gated at ROLL TIME against the -//! CREDITED KILLER only (the `Option` threaded through `kill_creature` → `roll_creature_loot`, -//! via `killer_needs_item`/`quest_gate_rows`) — a grouped quest-holder who didn't land kill credit -//! missed the drop entirely, and the credited killer's own take deleted the shared row for -//! everyone else. `killer_needs_item`/`needs_item_pure` are KEPT (still the need-check primitive, -//! now applied to the TAKER/viewer instead of the killer); `quest_gate_rows` (the old pre-roll -//! table filter) is REMOVED — nothing calls it anymore. -//! 2. **Reference pools (`reference_loot_template`) flatten RECURSIVELY AT IMPORT, with a cycle guard.** -//! A pool can itself reference another pool (`mincountOrRef < 0` inside `reference_loot_template`); -//! the importer's `resolve_ref_pool` walks that chain to full depth (not one level) and writes only -//! FLAT item rows into the SQL-loaded tables — so the module's roll (`roll_loot_rows`) stays the same -//! one-pass independent-rows-then-groups algorithm it always was; nesting is entirely an import-time -//! concern, invisible at roll time. use spacetimedb::{table, ReducerContext, Table}; use crate::actor::{ActionRefusal, ActionRefusalKind}; -use crate::character::game_character; // credit_purse's offline-recipient fallback (work-item 221) +use crate::character::game_character; use crate::game_group_member; // clone_quest_loot_for_group's GameObject roster read use crate::game_world_entity; use crate::quest::objective_kind; @@ -82,7 +53,7 @@ pub struct CorpseLoot { pub slot: u8, // loot-window slot index (0-based) pub item_entry: u32, // -> game_item_template.entry pub count: u32, - // END-APPENDED (work-item 187 slice 0, fixing 210's recorded divergence — module doc decision #1): + // END-APPENDED (fixing 210's recorded divergence, module doc decision): // does this row carry a QUEST-only item (rolled from a `quest_only` creature/pickpocket/ // gameobject-loot row)? Quest rows now roll UNCONDITIONALLY (the drop-chance still applies; only // the pre-roll killer gate is gone) — visibility (gateway) and takability (`apply_take_loot`) are @@ -90,11 +61,10 @@ pub struct CorpseLoot { // non-quest drop) keeps behaving exactly as before — additive auto-migration. #[default(false)] pub quest_only: bool, - // END-APPENDED (work-item 187 slice 0): `0` = the SHARED, not-yet-split row — any currently-needing + // END-APPENDED : `0` = the SHARED, not-yet-split row, any currently-needing // character may claim it, which triggers the per-member clone (`clone_quest_loot_for_group`); - // nonzero = a character guid — this specific row is a per-member CLONE reserved for exactly that - // character (minted the moment the shared row is first taken). Meaningless when `quest_only` is - // false UNLESS it's a group-loot WINNER-LOCKED row (below) — GENERALIZED (work-item 187 slices + // nonzero = a character guid, this specific row is a pernted the moment the shared row is first taken). Meaningless when `quest_only` is + // false UNLESS it's a group-loot WINNER-LOCKED row (below), GENERALIZED slices // 2-4): nonzero now also marks "this row belongs EXCLUSIVELY to this guid" for a NEED/GREED roll // winner whose bag was full at grant time (`resolve_roll`'s inventory-full fallback — the item // sits here until they free space and re-loot). Same predicate on both the gateway's visibility @@ -102,7 +72,7 @@ pub struct CorpseLoot { // guid, unconditionally". `#[default(0)]` — additive. #[default(0u64)] pub reserved_for: u64, - // END-APPENDED (work-item 187 slices 2-4, group loot methods): three columns the module stamps + // END-APPENDED (group loot methods): three columns the module stamps // at KILL TIME (`apply_group_loot_rules`, called from `combat::kill_creature` — never at // loot-open; see the module doc below for why). All three are meaningless (stay at their // defaults) for an ungrouped kill, an FFA-method group, or any `quest_only` row (the two systems @@ -144,9 +114,9 @@ pub struct CreatureLoot { pub chance_bp: u32, // drop chance in basis points (0..=10000); a group member's weight if group_id>0 pub count: u32, pub group_id: u32, // 0 = independent roll; >0 = pick-one group (≤1 member drops per kill) - // END-APPENDED (work-item 210): cmangos `ChanceOrQuestChance < 0` — the item is a QUEST-drop, only + // END-APPENDED : cmangos `ChanceOrQuestChance < 0`, the item is a QUEST-drop, only // ever visible to a player who currently needs it (an active matching COLLECT_ITEM objective — see - // `killer_needs_item`). Gated at ROLL TIME against the CREDITED KILLER (module doc, decision #1), + // `killer_needs_item`). Gated at ROLL TIME against the CREDITED KILLER (module doc, decision), // never re-evaluated later. `#[default(false)]` so existing rows (imported pre-210, or seeded) keep // rolling exactly as before (never quest-gated) — additive auto-migration. #[default(false)] @@ -200,16 +170,6 @@ pub fn scale_money_for_rank(money: u32, rank: u8) -> u32 { ((money as u64 * permille as u64) / 1000) as u32 } -/// Roll ONE generic loot table the SAME way independent rows (`group_id == 0`) test their own -/// `chance_bp`, and grouped rows (`group_id > 0`) are collected and rolled ONCE per group (pick-one, -/// weighted), but carries each winning row's `quest_only` flag through instead of discarding it -/// (work-item 187 slice 0): quest rows now roll into `game_corpse_loot` UNCONDITIONALLY — the -/// drop-CHANCE still applies; only the old pre-roll `killer_needs_item` GATE is gone (module doc, -/// decision #1's fix) — so the caller needs to know which winners were quest-only to stamp the new -/// `quest_only` column. This is the ONE roll body every family's wrapper calls (the "one -/// roll core" — `roll_loot_rows` below used to duplicate this whole algorithm minus the flag). Draws -/// from the module RNG (`ctx.random`), so it has no direct unit test; its pure primitives -/// (`loot_drops`/`group_pick`) are tested below. pub(crate) fn roll_loot_rows_quest_aware( ctx: &ReducerContext, rows: Vec<(u32, u32, u32, u32, bool)>, // (item_entry, chance_bp, count, group_id, quest_only) @@ -267,7 +227,7 @@ pub(crate) fn roll_loot_rows( .collect() } -/// Pure gate (work-item 187 slice 0): may `taker_guid` claim a `quest_only` row whose current +/// Pure gate : may `taker_guid` claim a `quest_only` row whose current /// reservation is `reserved_for`, given `taker_needs_item` (already resolved by the caller — mirrors /// `needs_item_pure`'s ctx/pure split)? An UNRESERVED row (`reserved_for == 0` — the shared row nobody /// has split yet) is claimable by anyone who currently needs it; a RESERVED row is claimable by its @@ -285,15 +245,6 @@ pub(crate) fn quest_take_allowed( reserved_for == taker_guid || (reserved_for == 0 && taker_needs_item) } -/// May `taker_guid` take a NON-quest `game_corpse_loot` row via the plain autostore path, given its -/// `withheld` bit (a live NEED/GREED roll owns the row — see `rolls.rs`), `reserved_for` (generalized -/// — see the struct doc: nonzero now ALSO covers a NEED/GREED winner's inventory-full fallback row), -/// `master_only`, and `designated_looter_guid`? Quest rows are gated separately by -/// [`quest_take_allowed`] (above) — `apply_take_loot` calls this one only when `!row.quest_only`. -/// `withheld` is checked FIRST and unconditionally: the per-viewer display filter hides these rows, -/// but display filtering is NOT enforcement — without this gate a crafted `take_loot` on the hidden -/// slot steals the item mid-roll, and the eventual `resolve_roll` grant silently no-ops while -/// ROLL_WON still fires (187 review finding #1). Pure. pub(crate) fn group_loot_take_allowed( withheld: bool, reserved_for: u64, @@ -313,7 +264,7 @@ pub(crate) fn group_loot_take_allowed( designated_looter_guid == 0 || designated_looter_guid == taker_guid } -/// Pure (work-item 187 slice 0): given `(character_guid, currently_needs_item)` pairs for every OTHER +/// Pure : given `(character_guid, currently_needs_item)` pairs for every OTHER /// group member (the taker already excluded by the caller), which should receive a fresh per-member /// clone when the shared `quest_only` row is first taken? Only the still-needing ones — a member who's /// already capped on the item (or no longer holds the quest) gets nothing, matching vanilla (the item @@ -326,7 +277,7 @@ pub(crate) fn clone_targets(other_members: &[(u64, bool)]) -> Vec { .collect() } -/// Pure (work-item 187 slice 0): `count` fresh, ascending loot-window slot indices past whatever is +/// Pure : `count` fresh, ascending loot-window slot indices past whatever is /// already used on a corpse (`used_slots`), so per-member quest clones never collide with the /// just-deleted shared row's slot or any sibling drop. Saturating (a corpse loot window is /// realistically far under 255 rows — the gateway's RAW `SMSG_LOOT_RESPONSE` builder already caps the @@ -360,11 +311,9 @@ pub(crate) fn needs_item_pure( .iter() .any(|&(quest_entry, kind, target_entry, required_count)| { kind == objective_kind::COLLECT_ITEM - && target_entry == item - && active_quests.contains(&quest_entry) - // Capped at the requirement: a killer already holding 10/10 stops seeing the drop - // (vanilla's behavior; the quest.rs completion check's same rule — review catch). - && held < required_count.max(1) + && target_entry == item + && active_quests.contains(&quest_entry) + && held < required_count.max(1) }) } @@ -413,7 +362,7 @@ pub(crate) fn killer_needs_item(ctx: &ReducerContext, killer: Option, item: } /// Purge every `game_corpse_loot` / `game_corpse_loot_eligible` row still parked on `guid`, BEFORE a -/// fresh roll ever writes to it. Two residue sources land here: (a) work-item 267's corpse-guid REUSE +/// fresh roll ever writes to it. Two residue sources land here: (a)'s corpse-guid REUSE /// (a harness SQL teardown or `debug_spawn_at_feet` skipping the decay reaper leaves a departed kill's /// `game_corpse_loot_eligible` snapshot behind for the next creature minted at that guid), and (b) /// the pickpocket/kill collision — `roll_pickpocket_loot` inserts `game_corpse_loot` rows at @@ -516,7 +465,7 @@ pub(crate) fn insert_corpse_rows( count, quest_only, reserved_for: 0, - // Group-loot stamping (work-item 187 slices 2-4) happens AFTER this fn returns, in + // Group-loot stamping happens AFTER this fn returns, in // `apply_group_loot_rules` (combat::kill_creature) — this insert always starts FFA. designated_looter_guid: 0, master_only: false, @@ -531,7 +480,7 @@ pub(crate) fn insert_corpse_rows( /// Roll a creature's loot table into `game_corpse_loot` rows on its corpse; returns whether anything /// dropped (so the caller sets `UNIT_DYNFLAG_LOOTABLE`). Data-driven: reads `game_creature_loot` for /// `creature_entry`, then rolls independent rows + one weighted pick per group with the -/// `roll_loot_rows_quest_aware` core (work-item 187 slice 0: `quest_only` rows roll UNCONDITIONALLY +/// `roll_loot_rows_quest_aware` core: `quest_only` rows roll UNCONDITIONALLY /// now — no killer gate — the flag just rides along onto the inserted row for the gateway/take-path to /// gate per-viewer/per-taker instead), inserting the winners at sequential loot slots with a fresh /// `reserved_for = 0` (unclaimed). No table rows → nothing drops (no universal placeholder). Shared by @@ -554,7 +503,7 @@ pub(crate) fn roll_creature_loot( } // =========================================================================================== -// LOOT-FAMILY COMPLETENESS (work-item 210) — the four families `game_creature_loot` never covered: +// LOOT-FAMILY COMPLETENESS, the four families `game_creature_loot` never covered: // pickpocket / gameobject-chest / skinning / fishing. All four share the SAME row shape as // `CreatureLoot` (item/chance_bp/count/group_id, some also carrying `quest_only`); the importer maps // cmangos's `*_loot_template` family onto them (see importer's `parse_loot_family`). Public + no @@ -617,12 +566,6 @@ pub struct GameSkinningLoot { pub group_id: u32, } -/// `fishing_loot_template` — a ZONE-keyed catch table (`zone_id` is a cmangos/AreaTable zone id, NOT a -/// creature/loot-id indirection — cmangos's `fishing_loot_template.entry` IS the zone). Rolled by -/// `professions::apply_fish` once the caster's zone is resolved (`terrain::zone_id_at`, the work-item -/// 209/375 terrain-area one-hop-parent-walk idiom); an unresolved zone OR a zone with no rows (or a roll that -/// lands on nothing) falls back to the flat `FISH_POOL`, byte-identical to the pre-210 alpha. No -/// `quest_only` — fishing junk isn't quest-gated in the family this slice imports. [static] #[table(accessor = game_fishing_loot, public, index(accessor = by_zone, btree(columns = [zone_id])))] pub struct GameFishingLoot { #[primary_key] @@ -636,9 +579,9 @@ pub struct GameFishingLoot { } /// Roll a creature's PICKPOCKET table into `game_corpse_loot` rows keyed on the LIVE target's guid, then -/// refresh `UNIT_DYNFLAG_LOOTABLE` (`refresh_lootable`, work-item 186) so a still-alive-but-pickpocketed +/// refresh `UNIT_DYNFLAG_LOOTABLE` (`refresh_lootable`) so a still-alive-but-pickpocketed /// creature shows the loot cursor if anything rolled. Quest-only rows roll UNCONDITIONALLY now -/// (work-item 187 slice 0 — no rogue gate; the flag rides onto the row for per-viewer/per-taker gating +///, no rogue gate; the flag rides onto the row for per-viewer/per-taker gating /// downstream). A no-row table (or an all-miss roll) inserts nothing and never touches the flag — the /// existing money-only path (spell/cast.rs) is unaffected either way. Called from `E_PICKPOCKET` /// (spell/cast.rs), inside the SAME `!target.pickpocketed` once-gate the copper roll already uses, so @@ -659,11 +602,11 @@ pub(crate) fn roll_pickpocket_loot(ctx: &ReducerContext, creature_entry: u32, ta refresh_lootable(ctx, target_guid); } -/// On the FIRST take of a shared `quest_only` row (work-item 187 slice 0, `apply_take_loot`'s caller): +/// On the FIRST take of a shared `quest_only` row (`apply_take_loot`'s caller): /// mint one independent, per-member `reserved_for` clone for every OTHER member of `taker_guid`'s group /// who currently still needs `item_entry` — so a grouped quest-holder's copy doesn't vanish the instant /// a fellow member loots theirs. A solo `taker_guid` (`group_of` returns `None`) touches nothing — -/// solo behavior is unchanged (design requirement #4). Clones land at FRESH slots past whatever is +/// solo behavior is unchanged (design requirement). Clones land at FRESH slots past whatever is /// already on the corpse (`next_free_slots`), never colliding with the just-deleted original's slot or /// any sibling drop. Reads `by_group`, an indexed group-scoped lookup, so it costs one clone per /// OTHER member whatever the group's size, up to 39 in a full Raid. [server] @@ -992,7 +935,7 @@ pub(crate) fn apply_loot_money( /// Credit `share` copper to `recipient_guid`'s purse: the live `WorldEntity.money` if currently /// online (relayed to their own connection as `PLAYER_FIELD_COINAGE`), else the durable -/// `Character.money` row directly (work-item 221 — an OFFLINE grouped recipient still gets paid; +/// `Character.money` row directly, an OFFLINE grouped recipient still gets paid; /// `build_player_entity` loads `character.money` back into the entity at their next login). Saturating, /// mirroring the looter's own transfer in [`apply_loot_money`]. No-op if the guid resolves to neither /// (a deleted character mid-flight) — never panics on a stale snapshot row. @@ -1146,10 +1089,10 @@ mod tests { assert!(scale_money_for_rank(u32::MAX, 3) >= u32::MAX / 2); } - // ---- LOOT-FAMILY COMPLETENESS (work-item 210) ---- + // ---- LOOT-FAMILY COMPLETENESS ---- /// `needs_item_pure` — the pure decision behind `killer_needs_item`: a COLLECT_ITEM objective on an - /// ACTIVE quest matching `item` says yes; a KILL_CREATURE objective (wrong kind), a different item + /// TIVE quest matching `item` says yes; a KILL_CREATURE objective (wrong kind), a different item /// (wrong target), or an objective whose quest ISN'T in `active_quests` (a stale/rewarded quest's /// leftover objective row) all say no. #[test] @@ -1286,23 +1229,6 @@ mod tests { assert!(shared_row.withheld); } - // ---- Group loot methods (work-item 187) slice 0: per-viewer quest drops ---- - - // NOTE on `roll_loot_rows_quest_aware`: like `roll_loot_rows` before it, it draws from - // `ctx.random` and has no direct unit test (the module crate has no `ReducerContext` test harness - // by design — rule: never mock it, extract + test pure functions instead). Its pure primitives - // (`loot_drops`/`group_pick`, exercised above) are UNCHANGED by this slice. The actual behavioral - // change — "a quest_only row rolls regardless of any player's quest state" — is a STRUCTURAL fact - // instead: the function's signature carries no "needs"/killer closure or parameter at all (compare - // against the OLD `quest_gate_rows(rows, has_matching_quest)` this replaces, which took one), so - // there is nothing left in the roll path capable of gating on it. Verified live via the wire - // harness's group scenario (see the work-item's runbook note) rather than faked here. - - /// `quest_take_allowed` — the TAKE-time gate: an UNRESERVED row (`reserved_for == 0`, the shared - /// row nobody split yet) is claimable by anyone who currently needs the item (a non-quest-holder - /// can't snipe it); a RESERVED row is claimable by its reserved owner UNCONDITIONALLY — the - /// reservation is the grant, need was checked at clone time. This MUST agree with the gateway's - /// `quest_row_visible_to_viewer` truth table (reads.rs) or a viewer sees rows they can't take. #[test] fn quest_take_allowed_admits_the_reservee_unconditionally_and_needing_takers_on_shared_rows() { // Unreserved (shared) row: any needing taker is admitted; a non-needing one is rejected. @@ -1321,7 +1247,7 @@ mod tests { assert!( quest_take_allowed(7, 7, false), "the reservation IS the grant — re-checking need here would strand a reservee whose need \ - lapsed post-clone with a visible-but-untakeable row (187 slice 0 review finding); mirrors \ + lapsed post-clone with a visible-but-untakeable row; mirrors \ quest_row_visible_to_viewer's 'reservation is authoritative' arm" ); } @@ -1332,9 +1258,6 @@ mod tests { /// `designated_looter_guid` restricts to that guid; the zero/false baseline is plain FFA. #[test] fn group_loot_take_allowed_gates_withheld_reserved_master_and_designated_rows() { - // WITHHELD (live roll): nobody autostores it — this is exactly the row shape a mid-roll - // GROUP/NBG item has (withheld=true, everything else zero/false), the crafted-take exploit - // shape the 187 review caught. Checked before every other flag. assert!( !group_loot_take_allowed(true, 0, false, 0, 7), "a live roll owns the row" @@ -1406,7 +1329,7 @@ mod tests { assert_eq!(next_free_slots(&[255], 2), vec![255, 255]); } - // ---- Money-loot split (work-item 221) ---- + // ---- Money-loot split ---- /// SOLO passthrough: an empty `recipients` slice (no `game_corpse_loot_eligible` snapshot) always /// credits the WHOLE amount to the looter in a single entry — byte-identical to the pre-221 diff --git a/module/src/loot/rolls.rs b/module/src/loot/rolls.rs index 9354bbbb5..5f1a1d15f 100644 --- a/module/src/loot/rolls.rs +++ b/module/src/loot/rolls.rs @@ -1,58 +1,3 @@ -//! GROUP LOOT METHODS (work-item 187 slices 1-4) — round-robin / need-greed rolls / master looter. -//! Split out of `loot.rs` into its own submodule: the decision enum, the rr cursor, -//! `LootRoll`/`LootRollVote`, vote/resolve/sweep/disband, and the realm-core plane. Pure code motion -//! — every gate and grant below is byte-identical to before the split. -//! -//! Timing decision (diverges from the work item's own draft, documented here): ALL group-loot -//! stamping — round-robin/master designation AND spawning need/greed rolls — happens at KILL TIME -//! (`apply_group_loot_rules`, called once from `combat::kill_creature` right after -//! `roll_creature_loot`), not lazily "at first loot-open" as the work item's flow draft describes. -//! Folding it into the kill path (already this slice's territory) avoids a NEW gateway reducer + -//! CMSG_LOOT dispatch change just to detect "first viewer" — and the observable result is -//! equivalent: eligible members get `SMSG_LOOT_START_ROLL` immediately after the kill instead of -//! exactly-when-someone-opens-the-corpse. Round-robin designation was ALREADY spec'd for -//! KILL-time creation in the work item's own design ("corpse gets `designated_looter_guid` -//! stamped AT CREATION"), so this only extends that timing to the roll-spawn half too. -//! -//! Relay-pattern decision: rolls/master-list notifications reuse the EXISTING `game_group_event` -//! per-recipient relay (`crate::group::push_event`) instead of a new gateway-subscribed table — see -//! `lyracore_shared::loot_roll`'s module doc for the full rationale. The actual roll STATE -//! (`LootRoll`/`LootRollVote` below) is never read by the gateway for the CLIENT's sake. -//! -//! **Where the roll DECISION lives.** `LootRoll`/`LootRollVote` are authoritative on -//! REALM-CORE, alongside `game_group`/`game_group_member` — a roll's audience is who is in the -//! group, not where anyone stands, exactly like the membership it is snapshotted from. Kill-time -//! creation (`start_roll`, below) still runs on the corpse's own WORLD SHARD unconditionally — combat -//! resolution has no path to another database — so in a sharded deployment this write is a TRANSIENT -//! staging copy: the gateway's loot-roll relay (`gateway/src/world/loot.rs::relay_tick`) promotes it -//! onto realm-core (`realm_loot_op`'s `loot_op::START`, calling `insert_roll_rows` again there WITHOUT -//! re-pushing `ROLL_START` — the popup already fired locally) and clears the shard's copy -//! (`clear_promoted_loot_roll`). Voting then routes to realm-core too (`loot_op::VOTE` → `cast_vote_on` -//! on realm-core's `ctx.db`), so `remove_member`'s disband branch — unchanged, still -//! `crate::loot::force_resolve_rolls_for_disband` — resolves a live roll in the SAME transaction as -//! the membership change, on the SAME database, with no mirror and no round trip: whichever database -//! `remove_member` executes on is also the one its `game_loot_roll` rows are the truth on. Unsharded, -//! none of this promotion/routing ever runs (`WorldStore::realm_store()` answers `None`), so the roll -//! lives and dies on one database exactly as it did before this issue — byte-identical. -//! -//! **What did NOT move.** Only the roll — the group-scoped decision — travels. `game_corpse_loot` -//! (which corpse, which slot, item quality) and the actual item GRANT stay on the world shard with -//! their existing escrow guarantees (`items::grant_item`); a winner decided on realm-core is handed -//! their item by `settle_loot_roll`, an operator reducer the relay calls on the corpse's OWN shard -//! after observing the `ROLL_WON` event. `game_corpse_loot_eligible` (who is in range to roll) is -//! computed spatially at kill time and stays world-shard-local too — it is `apply_group_loot_rules`'s -//! own `recipients` snapshot, not part of the roll's realm-core state. -//! -//! **The new case.** A roll's PARTICIPANTS could not previously be on different shards — -//! `kill_reward_recipients` only ever names members who are physically near the corpse, i.e. on its -//! own shard, at the moment the roll starts. The new case is a participant LEAVING that -//! shard mid-roll (a portal, a dungeon entry) during the 60s window. Before, that voter's row was -//! simply unreachable from wherever they went — their vote auto-passed at the deadline, same as being -//! offline. Now it is reachable: voting is realm-core state, and the gateway routes a vote -//! through whichever shard the player is CURRENTLY on, so a mid-roll shard-hopper can still vote from -//! their new location. No special-casing was needed for this — it falls out of routing votes to -//! realm-core rather than to "the shard that created the roll". - use spacetimedb::{reducer, table, Identity, ReducerContext, Table}; use crate::game_corpse_loot; @@ -83,7 +28,7 @@ pub struct CorpseLootEligible { /// A live NEED/GREED/NBG roll on one `game_corpse_loot` row. Private — the gateway never reads this /// (relayed via `game_group_event` instead, see the module doc above). `resolved` is the exactly-once -/// resolution guard (work-item 187 trap): set the moment `resolve_roll` decides an outcome, checked +/// resolution guard (trap): set the moment `resolve_roll` decides an outcome, checked /// FIRST on every entry point (the deadline sweep AND a landing vote can both reach the same roll). /// [entity] #[derive(Clone)] @@ -186,7 +131,7 @@ pub(crate) fn group_loot_decision_for_row( } } -/// Round-robin cursor advance (work-item 187 trap: "skipping offline members"): given a STABLE +/// Round-robin cursor advance (trap: "skipping offline members"): given a STABLE /// member ordering's online bitmap and the group's current `cursor`, return the chosen member's /// INDEX into that same ordering plus the cursor value to persist for NEXT time (wrapping). `None` /// if nobody is online (a fully-offline group — degrades to FFA, never panics/blocks looting). Pure. @@ -251,7 +196,7 @@ pub(crate) fn pick_roll_winner(rolls: &[u8]) -> Option { /// `combat::kill_creature` right after `roll_creature_loot`, for a grouped Loot Tag only. The /// corpse-eligibility rows are the one recipient set for designation, master loot, and rolls. /// -/// Work-item 187 trap ("solo player with method GROUP set: threshold rows must NOT roll"): a solo +/// trap ("solo player with method GROUP set: threshold rows must NOT roll"): a solo /// `recipients` (`len() < 2`, e.g. every other member out of XP range or dead) skips ALL group-loot /// handling too — vanilla's "party size 1 -> direct loot" applies to loot exactly like it does to /// the XP split, so the same recipient-count gate covers both. @@ -553,9 +498,6 @@ pub(crate) fn settle_roll_grant( .filter(&corpse_guid) .find(|l| l.slot == slot) else { - // AC#4 sweep. This return is the one that ATE A WINNER'S ITEM (the review): a slow - // voter had `CORPSE_DECAY_MICROS == ROLL_WINDOW_MICROS` reap the corpse out from under the - // roll, settlement found no row, and it returned with no grant, no error and no log. // // It cannot be an error, because the gateway fans `settle_loot_roll` out to every shard and // "no row here" is the normal answer on all but one. So it is logged at INFO and worded so @@ -599,7 +541,7 @@ pub(crate) fn settle_roll_grant( } } -/// Resolve exactly once (the `resolved` flag guard — work-item 187 trap): any NEED beats any GREED; +/// Resolve exactly once (the `resolved` flag guard, trap): any NEED beats any GREED; /// a TIE within the winning tier re-rolls JUST the tied members internally (bounded — 1-100 rolls /// collide vanishingly rarely, `MAX_TIE_REROLLS` is generous headroom) and announces once; all-pass /// unlocks the row FFA-in-group (no `ROLL_WON` — vanilla shows no "won" line either). The winner is @@ -642,7 +584,7 @@ fn resolve_roll(ctx: &ReducerContext, roll: &LootRoll, votes: &[LootRollVote], r }; let winning_roll = rolls[w]; let winner_guid = votes[tier[w]].voter_guid; - let winning_vote = votes[tier[w]].vote; // NEED or GREED — the tier that actually won (finding #3) + let winning_vote = votes[tier[w]].vote; // The winning tier determines whether the vote was Need or Greed. // Grant, then finalize the CorpseLoot row per the outcome (module doc: 068 mail is deferred). // On a single database this runs inline, right here, exactly as before. On REALM-CORE @@ -695,7 +637,7 @@ fn cleanup_roll(ctx: &ReducerContext, roll_id: u64) { ctx.db.game_loot_roll().id().delete(roll_id); } -/// Deadline sweep (work-item 187 trap: "disconnected member mid-roll: their vote auto-passes at +/// Deadline sweep trap: "disconnected member mid-roll: their vote auto-passes at /// deadline"): scans every UNRESOLVED roll whose deadline has elapsed, auto-passes any still-`!voted` /// member (in place — never blocks), then resolves. `pub(crate)` — exposed for the orchestrator's /// scheduled-GC tick (`gc.rs`, NOT edited here per instructions): wire one call @@ -725,7 +667,7 @@ pub(crate) fn sweep_loot_rolls(ctx: &ReducerContext) { } } -/// Work-item 187 trap ("disband mid-roll -> resolve to sole member"): called from +/// trap ("disband mid-roll -> resolve to sole member"): called from /// `group::remove_member`'s full-disband branch with the FULL former membership (`member_guids`, /// leaver included) and the sole survivor if exactly one remains. Force-resolves every UNRESOLVED /// roll whose ENTIRE voter set belonged to the disbanding group: with a sole survivor, grants them diff --git a/module/src/mail_escrow.rs b/module/src/mail_escrow.rs index 6194780e8..954459525 100644 --- a/module/src/mail_escrow.rs +++ b/module/src/mail_escrow.rs @@ -628,12 +628,10 @@ pub(crate) fn apply_commit( if payment == Some(CodPayment::Hold) { return Err(format!( "mail escrow {escrow_id}: mail {cod_mail_id} owes {sender_guid} no delivered price yet \ - — holding the payment" + — holding the payment" )); } let subject = if pays_cod { - // A payment fenced by the previous Gateway stored this prefix. The client adds it again - // for a COD_PAYMENT letter. draft .subject .strip_prefix(LEGACY_COD_PAYMENT_PREFIX) @@ -727,7 +725,7 @@ pub(crate) fn apply_take_fence( if money != expect_money { return Err(format!( "mail {mail_id} holds {money} copper, not the {expect_money} this take was driven for \ - — refusing to fence an amount the payout would not match" + — refusing to fence an amount the payout would not match" )); } sink.clear_mail_money(mail_id); diff --git a/module/src/mail_text.rs b/module/src/mail_text.rs index 707db9097..a2e14455b 100644 --- a/module/src/mail_text.rs +++ b/module/src/mail_text.rs @@ -120,11 +120,6 @@ pub(crate) fn plan_copy_text(row: Option<&Mail>, caller_guid: u64) -> CopyTextPl } /// Sets COPIED on the mail and files its body as durable item text, keyed by the mail's own id. -/// This is the ONE-TRANSACTION half of a Letter Copy: `mail_text.rs` writes `game_mail` directly -/// (rather than through a `mail.rs` helper) because `module/src/mail.rs` is shared ground with -/// other mail work landing in the same window — a new function here keeps this change out of its -/// way. -/// /// Replay-safe: a retry that reaches here again before GRANTED is ever set (bags filled, logout, /// timeout, a crash between here and the Home Shard grant) re-runs harmlessly. The text row is /// reused when it already exists: `mail::returned` keeps a returned mail's id, so a letter a second diff --git a/module/src/motion.rs b/module/src/motion.rs index ceccdf25b..7f2e5e679 100644 --- a/module/src/motion.rs +++ b/module/src/motion.rs @@ -22,10 +22,7 @@ //! `game_entity_motion_pending` **iff** that mover has motion the tick has not published yet; the //! tick drains (deletes) every row it publishes. An entity that did not move therefore has no row //! and is not touched — the tick's cost is O(movers since the last firing), never O(world). -//! -//! `seq` is NOT staged here: it lives on the public row and is bumped at publish time, exactly as -//! `movement_update` used to bump it, so per-mover ordering/de-duplication on the gateway side is -//! unchanged. + //! //! # Discrete transitions (the part that is easy to get subtly wrong) //! @@ -120,7 +117,7 @@ pub struct MotionPublishSchedule { /// swim/run-walk-mode) is an edge the client animates off; dropping one shows a peer sliding into a /// run with no start, or landing with no jump. /// -/// This mirrors the gateway's own rule 1 (`world/coalesce.rs`, work-item 231: "any non-heartbeat +/// This mirrors the gateway's own rule 1 (`world/coalesce.rs`): "any non-heartbeat /// opcode is ALWAYS a state change"), one layer down — it is deliberately the SAME classification, /// only stricter about `SET_FACING`, which the gateway forwards eagerly because the module's stored /// heading feeds server-side facing checks. @@ -152,7 +149,7 @@ pub(crate) enum Coalesce { /// Publish the QUEUED one immediately (in this movement transaction, paying one sweep) and /// stage the new one. This is the only path that still costs a per-packet sweep, and it is /// reachable only when a player produces two input-state changes inside one tick window — with - /// the gateway's own 150 ms heartbeat coalescing (work-item 231) upstream, that is rare and + /// the gateway's own 150 ms heartbeat coalescing upstream, that is rare and /// self-limiting, never proportional to player count. FlushThenReplace, } @@ -542,7 +539,6 @@ mod tests { Some(&(HEARTBEAT, vec![1, 2, 3], 0)), "a staged heartbeat must reach the public relay on the next firing" ); - // ...and a second, later packet republishes with a bumped seq. w.movement(7, HEARTBEAT, &[4, 5, 6]); w.tick(); assert_eq!(w.published.get(&7), Some(&(HEARTBEAT, vec![4, 5, 6], 1))); @@ -581,7 +577,6 @@ mod tests { w.pending.remove(&7); w.tick(); assert!(!w.published.contains_key(&7)); - // ...and even if a packet were staged with no lifecycle cleanup at all (a delete site // missed, a race), the tick's own liveness gate still refuses to write the public row. w.movement(7, HEARTBEAT, &[9]); w.tick(); @@ -608,7 +603,7 @@ mod tests { survive — 'latest position wins' would publish the heartbeat and the peer would never \ play the jump" ); - // The heartbeat's fresher position is not lost forever: the next packet carries it. + w.movement(7, HEARTBEAT, &[0xCC]); w.tick(); assert_eq!( diff --git a/module/src/mount.rs b/module/src/mount.rs index 58121bc11..5d98e8f71 100644 --- a/module/src/mount.rs +++ b/module/src/mount.rs @@ -195,12 +195,6 @@ fn trained_for_spell(ctx: &ReducerContext, guid: u64, spell_id: u32, race: u8, c }) } -/// Does this heartbeat cross a 100 ms boundary on the CLIENT's own move clock? The movement -/// heartbeat's indoor check runs only when it does — ~10 Hz, fine enough to catch a doorway at run -/// speed (a rider covers ~1.4 yd per window) and coarse enough that a 30 Hz client does not buy 30 -/// area queries a second. STATELESS by construction, exactly like the 1 Hz rest and breath gates -/// beside it: the boundary is derived from the two timestamps the packet already carries, so there -/// is no per-mover throttle field to persist, reset on login, or leave stale. Pure. pub(crate) fn indoor_check_is_due(old_move_ms: u32, move_time_ms: u32) -> bool { move_time_ms / 100 != old_move_ms / 100 } @@ -375,10 +369,6 @@ mod tests { assert!(!mask_admits(u32::MAX, 33)); } - /// The heartbeat's 100 ms window, at the boundary it actually decides. Two heartbeats inside one - /// window skip the area query; the first heartbeat of a new window pays it. The clock is the - /// CLIENT's own `move_time_ms`, so a client that stops sending simply stops being checked — and a - /// mounted player who walks a doorway crosses at most one window before dismounting. #[test] fn indoor_check_is_due_only_on_a_100ms_boundary() { assert!(!indoor_check_is_due(0, 0)); diff --git a/module/src/nav.rs b/module/src/nav.rs index a2360fa6a..0a9f67313 100644 --- a/module/src/nav.rs +++ b/module/src/nav.rs @@ -1,4 +1,4 @@ -//! Nav grid: walkability + line-of-sight obstruction data (work-item 241, decision #8). +//! Nav grid: walkability + line-of-sight obstruction data See `docs/decisions.md` section 8. //! //! One row per terrain MCNK cell that has ANY obstruction, steep slope, or hole — fully-clear //! cells emit no row, so a missing chunk means "no obstacles known here" and every reader keeps @@ -202,7 +202,7 @@ pub fn import_nav_chunks_append(ctx: &ReducerContext, packed: String) -> Result< } // =========================================================================================== -// Runtime consumption (work-item 243) — the module-side wrappers over `lyracore_shared::nav`'s +// Runtime consumption, the module-side wrappers over `lyracore_shared::nav`'s // pure queries, gated on `game_config.nav_enabled` (default OFF; the 244 benchmark + live // verify flips it). Every wrapper degrades to the pre-243 straight-line behavior when the // flag is off or a chunk is missing. `route_step` holds position when no path exists. diff --git a/module/src/operations.rs b/module/src/operations.rs index 3f2e9a583..f1c1dcc63 100644 --- a/module/src/operations.rs +++ b/module/src/operations.rs @@ -31,9 +31,6 @@ pub fn debug_repair_after_publish(ctx: &ReducerContext) -> Result<(), String> { crate::talent::seed_talents(ctx); let talents = ctx.db.game_talent().count(); - // formerly `debug_seed_gm_tester`: (re)grant the seeded Tester (guid 1) `gm_level` 3 — needed on - // an already-migrated dev DB where `seed::init` did NOT re-run (work-item 223: the `gm_level` - // column auto-migrates existing rows to 0). No-op if the Tester character row is gone. if let Some(mut c) = ctx.db.game_character().guid().find(1) { c.gm_level = 3; ctx.db.game_character().guid().update(c); @@ -136,8 +133,6 @@ pub fn debug_repair_after_publish(ctx: &ReducerContext) -> Result<(), String> { // and its sky stays fine. let weather_climate = crate::weather::seed_weather_weights(ctx); - // --- schedule ensures (idempotent: no-op if a row is already present) --- - // formerly `debug_ensure_aura_schedule`: matches the 1s interval in `seed::init`. let aura_schedule = if ctx.db.game_aura_schedule().iter().next().is_some() { 0 } else { @@ -170,7 +165,6 @@ pub fn debug_repair_after_publish(ctx: &ReducerContext) -> Result<(), String> { 1 }; - // formerly `debug_ensure_ground_area_schedule`: matches the 500ms interval in `seed::init`. let ground_area_schedule = if ctx.db.game_ground_area_schedule().iter().next().is_some() { 0 } else { @@ -221,12 +215,7 @@ pub fn debug_repair_after_publish(ctx: &ReducerContext) -> Result<(), String> { 1 }; - // --- rearms (idempotent: always replace with the canonical row) --- - // formerly `debug_rearm_creature_tick`: re-arm the GLOBAL creature movement tick to 0.5s. Work-item - // 229: DEDICATED per-instance rows (armed via `debug_arm_instance_tick`) are deliberately left - // alone — this only ever touches the catch-all row, and doubles as the recovery path if that - // load-bearing row was ever deleted (every instance lacking a dedicated row stops ticking - // entirely, including the open world, without it). + // Restore the global tick without changing dedicated Instance tick rows. let move_sched = ctx.db.game_creature_move_schedule(); let stale_move: Vec = move_sched .iter() @@ -242,7 +231,6 @@ pub fn debug_repair_after_publish(ctx: &ReducerContext) -> Result<(), String> { instance_id: crate::creatures::GLOBAL_TICK_INSTANCE, }); - // formerly `debug_rearm_instance_reaper`: re-seed the 60s instance-reaper schedule row. let reaper_sched = ctx.db.game_instance_reaper_schedule(); let stale_reaper: Vec = reaper_sched.iter().map(|r| r.scheduled_id).collect(); for id in stale_reaper { @@ -349,9 +337,8 @@ pub fn debug_repair_after_publish(ctx: &ReducerContext) -> Result<(), String> { proc_profiles += 1; } - // row_count: total rows/rearms across every family this pass touched (work-item 216 provenance - // stamp) — one stamp for the whole repair pass, replacing the 13 separate per-family stamps the - // deleted reducers wrote. The trailing `+ 3` is the three schedule rows this always (re)arms + // row_count: total rows/rearms across every family this pass touched. One provenance + // stamp records the whole repair pass. The trailing `+ 3` is the three schedule rows this always (re)arms // unconditionally (creature tick + instance reaper + weather roll), which have no "was it // already present" signal worth stamping separately. let total = talents diff --git a/module/src/professions.rs b/module/src/professions.rs index 196c971f1..0a730d346 100644 --- a/module/src/professions.rs +++ b/module/src/professions.rs @@ -163,7 +163,7 @@ pub(crate) fn skin_corpse( return Err("corpse still has loot".to_string()); } - // Grant leather — DATA-DRIVEN (work-item 210): the corpse's creature template names a + // Grant leather, DATA-DRIVEN : the corpse's creature template names a // `skin_loot_id` (cmangos `skinning_loot_template`, level-banded across many creatures sharing a // band); roll it and grant every winner. `skin_loot_id == 0` (unimported / a seeded/test beast) OR // an empty/no-win roll falls back to the flat `LEATHER_ENTRY`/`LEATHER_COUNT` — byte-identical to @@ -218,7 +218,7 @@ pub(crate) fn skin_corpse( /// The small-fish FALLBACK pool — REAL low-level cmangos fish `game_item_template` entries (standard /// trade-goods present in the imported box). Used when a cast's zone has NO `game_fishing_loot` rows -/// (unimported, or off-slice) — since work-item 210 the real per-zone table (`game_fishing_loot`) is +/// (unimported, or off-slice), since the real per-zone table (`game_fishing_loot`) is /// tried FIRST; this is the "never comes up empty-handed" floor beneath it (mirrors the skinning /// `LEATHER_ENTRY` fallback). If a fish entry isn't in the imported `item_template`, `grant_item` /// returns Err and the whole `fish` rolls back (no half-catch) — so verify these three are in-box. @@ -260,7 +260,7 @@ pub(crate) fn apply_fish(ctx: &ReducerContext, guid: u64) -> Result<(), String> crate::skill::skill_line::FISHING, crate::skill::APPRENTICE_CAP, ); - // Zone-keyed catch (work-item 210, resolver hoisted to `terrain::zone_id_at`): resolve the + // Zone-keyed catch (resolver hoisted to `terrain::zone_id_at`): resolve the // caster's zone and roll its real table; an unresolved zone (unimported terrain/AreaTable, or // off-slice) rolls an empty Vec, which `roll_loot_rows` correctly turns into zero winners — // `pick_caught_fish` then floors to the flat pool either way. `?`-rollback: a full bag fails here @@ -364,7 +364,7 @@ pub(crate) fn apply_disenchant(ctx: &ReducerContext, guid: u64, slot: u8) -> Res /// ENCHANT the item in `target_slot` with `enchant_id`: validate the id (it must be a known enchant in /// `game_item_enchantment`), consume the enchanting mats, stamp `enchant_id` onto the instance, climb Enchanting. /// The core (resolved guid), shared by the `enchant_item` reducer + `debug_enchant_item` twin. The enchant -/// is server-REAL: `equipped_stat_bonus` now folds `enchant_stat(enchant_id, ..)`, so an equipped enchanted +/// is server-REAL: `equipped_stat_bonus` now folds `enchant_stat(enchant_id,..)`, so an equipped enchanted /// piece moves the effective-* readouts (swing/dodge/armor/crit/hit + max HP/mana via recompute_vitals). /// The client glow/green-text is DEFERRED (the 5875 client caches item stats by ENTRY). ROLLBACK: /// `remove_items` (the mats consume) is `?`-propagated BEFORE the instance is updated, so a missing-mats @@ -390,8 +390,7 @@ pub(crate) fn apply_enchant_item( // Consume the enchanting mats FIRST (the `?`-rollback point): not enough Strange Dust → Err here, // BEFORE the instance is stamped, so the item is unchanged and no skill is gained. crate::items::remove_items(ctx, guid, DISENCHANT_MATS_ENTRY, DISENCHANT_MATS_COUNT)?; - // Stamp the enchant onto THIS instance (the per-instance overlay). The overlay folds through - // `equipped_stat_bonus` for every effective-* consumer — server-real, no client display this slice. + let instances = ctx.db.game_item_instance(); inst.enchant_id = enchant_id; instances.guid().update(inst); @@ -510,13 +509,6 @@ mod tests { assert_eq!(LEATHER_COUNT, 1); } - // ---- FISHING (completing the 13) ---- - - /// Re-scoped (was `fish_grants_one_pooled_fish_and_climbs_fishing`): the "grants one" / "climbs - /// fishing" narrative modeled `apply_fish`'s grant+skill-up with a local loop that never calls - /// `apply_fish` (a ctx fn) — not exercised here. What's real and worth guarding: the pool itself, - /// which `apply_fish` indexes with `ctx.random::() % FISH_POOL.len()` — a drift guard on its - /// exact non-empty, real-entry contents (an empty pool would divide by zero at cast time). #[test] fn fish_pool_is_nonempty_and_lists_the_real_low_level_fish_entries() { assert!( @@ -549,12 +541,6 @@ mod tests { assert_eq!(pick_caught_fish(None, 1), FISH_POOL[1]); } - // ---- ENCHANTING: disenchant (completing the 13) ---- - - /// Re-scoped (was `disenchant_consumes_item_and_grants_dust_and_skill`): the consume/grant/skill-up - /// narrative modeled `apply_disenchant`'s effect with local counters that never call it (a ctx fn) — - /// not exercised here. What's real and worth guarding: the mats entry/count `apply_disenchant` grants, - /// a drift guard against the wire-facing item id ever moving. #[test] fn disenchant_mats_are_the_real_strange_dust_entry() { assert_eq!( diff --git a/module/src/quest.rs b/module/src/quest.rs index 31ef90d18..0a7b1a8eb 100644 --- a/module/src/quest.rs +++ b/module/src/quest.rs @@ -7,11 +7,11 @@ //! Data model — a clean header + list shape (NOT cmangos's one fat row), so the runtime joins are //! direct and the importer maps cmangos's flattened columns onto normalized rows: //! -//! - [`QuestTemplate`] — the quest header (level gate, title, money/XP reward). [static] -//! - [`QuestObjective`] — a quest's objective list (today: kill N of a creature). [static] +//! - [`QuestTemplate`] , the quest header (level gate, title, money/XP reward). [static] +//! - [`QuestObjective`], a quest's objective list (today: kill N of a creature). [static] //! - [`QuestRewardItem`] — a quest's guaranteed reward items. [static] -//! - [`CreatureQuest`] — which creature STARTS (`!`) / ENDS (`?`) which quest (many-to-many). [static] -//! - [`CharacterQuest`] — a character's live quest log: one row per accepted quest with per-objective +//! - [`CreatureQuest`] , which creature STARTS (`!`) / ENDS (`?`) which quest (many-to-many). [static] +//! - [`CharacterQuest`], a character's live quest log: one row per accepted quest with per-objective //! progress counts, RLS-scoped to the owner. Born in `accept_quest`. [entity] //! //! Flow: `accept_quest(giver, quest)` validates the giver offers it + the player qualifies, then opens @@ -28,7 +28,7 @@ use spacetimedb::{table, Identity, ReducerContext, Table}; use crate::game_area_trigger; use crate::game_gameobject; // GAMEOBJECT quest givers (e.g. Wanted Poster, Lost Guards corpses) -use crate::game_item_instance; // ITEM quest givers (work-item 194: item_template.start_quest) +use crate::game_item_instance; use crate::game_item_template; use crate::game_world_entity; use crate::game_xp_event; // quest XP "+N experience" relay (non-kill SMSG_LOG_XPGAIN) @@ -148,7 +148,7 @@ pub mod quest_role { /// `CMSG_QUESTGIVER_*` op, so this only rejects clearly-out-of-range abuse (mirrors `loot`/vendor range). const QUEST_GIVER_RANGE_SQ: f32 = 100.0; -/// Party quest-SHARE range (work-item 194): reuses the group system's own kill-reward range +/// Party quest-SHARE range : reuses the group system's own kill-reward range /// (`crate::group::GROUP_XP_RANGE_SQ`, vanilla's 74yd `CONFIG_FLOAT_GROUP_XP_DISTANCE`) rather than /// the tight NPC-interaction [`QUEST_GIVER_RANGE_SQ`] (10yd) — sharing a quest is a party-visibility /// action (no walking up to a person), the same range the game already treats as "close enough to @@ -160,7 +160,7 @@ const PARTY_SHARE_RANGE_SQ: f32 = crate::group::GROUP_XP_RANGE_SQ; /// (`gateway/src/stdb/reads.rs`) — both sides model the same 20-slot vanilla quest log. pub(crate) const MAX_QUEST_LOG_SIZE: usize = 20; -/// Vanilla level cap (quest XP→money conversion gate, work-item 194(e)) — matches `xp::xp_to_next_level`'s +/// Vanilla level cap (quest XP→money conversion gate,(e)), matches `xp::xp_to_next_level`'s /// own hardcoded cap check (`level >= 60` there too; not re-exported as a shared const, so this is a /// second literal by necessity — pinned by a test alongside `xp::xp_to_next_level`'s own cap tests). const QUEST_MAX_LEVEL_PAYOUT: u32 = 60; @@ -230,7 +230,7 @@ pub struct QuestTemplate { // for a repeatable quest is reset in place (counts zeroed, rewarded=false) rather than rejected. #[default(false)] pub repeatable: bool, - // --- forward chains + timed quests (work-item 194, END-appended, #[default(0)] → additive + // --- forward chains + timed quests, END-appended, #[default(0)] → additive // auto-migration) --- // cmangos NextQuestId/NextQuestInChain: the successor quest auto-offered on this quest's turn-in // (0 = no successor). `prev_quest_id`/auto_finish already do the ACCEPT-side gating + the @@ -420,7 +420,7 @@ pub struct CharacterQuest { pub quest_entry: u32, // -> QuestTemplate.entry pub counts: Vec, // per-objective kill progress, indexed by QuestObjective.obj_index pub rewarded: bool, // true once turned in — the row stays to block a repeat - // --- timed quests (work-item 194, END-appended, #[default(...)] → additive auto-migration) --- + // --- timed quests, END-appended, #[default(...)] → additive auto-migration) --- // Micros-since-epoch deadline stamped by `apply_accept_quest` from `QuestTemplate.limit_time` // (`ctx.timestamp` micros + `limit_time * 1_000_000`); 0 = untimed (the vast majority of quests — // existing rows default to this, unchanged behavior). Cleared back to 0 the instant the quest @@ -604,14 +604,14 @@ enum GiverKind { /// A spawned gameobject (`game_gameobject`) — checked against `game_gameobject_quest` /// (some quests, like GO 68 "Wanted Poster", have no creature giver at all). GameObject(u32), - /// Work-item 194 (item-starts-quest): a USED item whose template names `start_quest` + /// (item-starts-quest): a USED item whose template names `start_quest` /// (cmangos `item_template.startquest`, the "dropped tabard starts a quest" pattern) — carries the /// item's TEMPLATE entry (checked against `ItemTemplate.start_quest`, never a `game_*_quest` /// relation table). The gateway sends the item's OWN instance guid as the "giver" guid /// (`CMSG_USE_ITEM` → `SMSG_QUESTGIVER_QUEST_DETAILS`); `validate_giver` resolves it back against /// the PLAYER'S OWN `game_item_instance` row (never someone else's bag). Satisfies START only. Item(u32), - /// Work-item 194 (sharing): a live PLAYER entity that shared this quest via `push_quest_to_party` + /// (sharing): a live PLAYER entity that shared this quest via `push_quest_to_party` /// — carries the SHARER's character guid. Re-validated FRESH here every time (grouped + in range, /// via [`validate_party_giver`]) — a spoofed/stale giver_guid can never self-authorize, and /// [`giver_has_quest_role`] additionally requires the sharer to be CURRENTLY, ACTIVELY on the @@ -630,7 +630,7 @@ fn validate_giver( ) -> Result { if let Some(giver) = ctx.db.game_world_entity().guid().find(giver_guid) { if giver.is_player() { - // Work-item 194 (sharing): the ONLY case a live PLAYER entity is a valid giver — a + // (sharing): the ONLY case a live PLAYER entity is a valid giver, a // party-share re-validated fresh (never trusts the earlier push_quest_to_party call). return validate_party_giver(ctx, player, giver_guid, &giver); } @@ -671,7 +671,7 @@ fn validate_giver( } return Ok(GiverKind::GameObject(go.template_entry)); } - // Work-item 194 (item-starts-quest): not a live creature/GO/player entity — try the PLAYER'S + // (item-starts-quest): not a live creature/GO/player entity, try the PLAYER'S // OWN item instance (never someone else's bag; a used item's instance guid is what the gateway // sends as "giver" for CMSG_USE_ITEM's start_quest path). Tried LAST — the common paths above are // cheap and never collide with an item's HIGHGUID_ITEM-tagged guid space anyway. @@ -702,7 +702,7 @@ pub(crate) fn quest_ender( .map(|go| QuestGiver::Gameobject(go.template_entry)) } -/// Work-item 194 (sharing): validates a live PLAYER `giver_guid` as a party-share giver for `player` +/// (sharing): validates a live PLAYER `giver_guid` as a party-share giver for `player` /// — grouped with the accepting player (the SAME group id), on the same map/instance, within /// [`PARTY_SHARE_RANGE_SQ`]. Every field is re-read LIVE here — never trusts that /// `push_quest_to_party` ran, so a spoofed/stale giver_guid that no longer names a real grouped, @@ -752,7 +752,7 @@ fn giver_has_quest_role( match *giver { GiverKind::Creature(entry) => creature_has_quest_role(ctx, entry, quest_entry, role), GiverKind::GameObject(entry) => gameobject_has_quest_role(ctx, entry, quest_entry, role), - // Item-starts-quest (work-item 194): satisfies START iff the item's OWN template names this + // Item-starts-quest : satisfies START iff the item's OWN template names this // quest — never END (an item never completes a quest). GiverKind::Item(entry) => { role == quest_role::START @@ -763,7 +763,7 @@ fn giver_has_quest_role( .find(entry) .is_some_and(|t| t.start_quest == quest_entry) } - // Party share (work-item 194): satisfies START iff the sharer is CURRENTLY, ACTIVELY on the + // Party share : satisfies START iff the sharer is CURRENTLY, ACTIVELY on the // quest (an un-rewarded, un-failed row) — never END. This is the hard gate against a spoofed // giver_guid self-authorizing: even a genuinely grouped, in-range peer can't hand out a quest // they aren't actually holding right now. @@ -821,7 +821,7 @@ pub(crate) fn accept_gates( // (exactly like any other active quest); it never erases the fact the quest was completed before, and // `apply_turn_in_quest` always marks it rewarded again on the next turn-in. Any other existing row // (still active, or a non-repeatable already-rewarded row) is a hard duplicate. - // Work-item 194 (timed quests): an existing FAILED row (expired past its deadline) is re-acceptable + // (timed quests): an existing FAILED row (expired past its deadline) is re-acceptable // exactly like a repeatable-rewarded one — reset in place, never a hard duplicate. This is // independent of `repeatable`: ANY timed quest can be retried after it fails, not just repeatable // ones (vanilla: a failed escort/timed quest can always be picked up again). @@ -883,7 +883,7 @@ pub(crate) fn request_accept_quest( // Package ask as well. It hands back the row `apply_accept_effects` resets in place, so the // reset-in-place-vs-insert decision costs no second point-scan of the same row. let existing = accept_gates(ctx, &player, &tmpl)?; - // Timed quests (work-item 194): a `limit_time > 0` template stamps a deadline (accept-time + // Timed quests : a `limit_time > 0` template stamps a deadline (accept-time // ctx.timestamp micros + limit_time seconds, as micros); 0 = untimed (the vast majority). let deadline_micros = if tmpl.limit_time > 0 { ctx.timestamp.to_micros_since_unix_epoch() + (tmpl.limit_time as i64) * 1_000_000 @@ -1090,7 +1090,7 @@ pub(crate) fn request_turn_in_quest( if cq.rewarded { return Err("quest already turned in".to_string().into()); } - // Work-item 194 (timed quests): a FAILED (expired) quest can't be turned in — it must be + // (timed quests): a FAILED (expired) quest can't be turned in, it must be // re-accepted first (the duplicate guard in `apply_accept_quest` lets that through). if cq.failed { return Err("quest has expired".to_string().into()); @@ -1164,7 +1164,7 @@ pub(crate) fn request_turn_in_quest( lyracore_shared::quest::xp_reward(tmpl.quest_level) }; let xp = crate::xp::rated_xp(ctx, base_xp); - // Level-cap payout (work-item 194(e)): at the vanilla level cap, quest XP is USELESS (no next + // Level-cap payout(e)): at the vanilla level cap, quest XP is USELESS (no next // level to fill) — real vanilla instead converts it to copper. Money-converted, never granted as // XP (vanilla behaviour: a capped character never dings off a quest). Below the cap, unchanged: XP // granted normally, no conversion. `max_level_money_reward` (the gateway's quest-log-preview stub, @@ -2783,11 +2783,11 @@ pub(crate) fn on_areatrigger_entered(ctx: &ReducerContext, player_guid: u64, tri ); } -/// One cmangos `areatrigger_teleport` row — a dungeon entrance/exit portal (work-item 225). +/// One cmangos `areatrigger_teleport` row, a dungeon entrance/exit portal. /// Loaded by the importer's `--dump` "globals" family /// (`importer/src/main.rs::build_areatrigger_teleport_sql`). Module-only: deliberately NOT `public` — /// no client or gateway ever reads a teleport TARGET directly, only its EFFECT (the cross-map teleport -/// handshake, work-item 224, which already carries its own `game_teleport_event` wire path), so this +/// handshake,, which already carries its own `game_teleport_event` wire path), so this /// table needs no gateway binding at all (danger-zones.md §1's "new table → regenerate bindings" rule /// only applies to tables a gateway subscription reads). [`apply_enter_areatrigger`] looks this up by /// `trigger_id`; a hit routes the player through [`crate::world::teleport_player`], a miss leaves the @@ -3036,7 +3036,7 @@ pub(crate) fn enter_sessionless_areatrigger( ) } -/// The shared core behind [`enter_areatrigger`] and `debug_enter_areatrigger` (work-item 225). Quest +/// The shared core behind [`enter_areatrigger`] and `debug_enter_areatrigger`. Quest /// credit fires FIRST and UNCONDITIONALLY (unchanged from pre-225 behavior), then — iff `trigger_id` /// has an imported `game_areatrigger_teleport` row — the player is routed through 224's cross-map /// teleport. A trigger can be BOTH a quest-explore objective AND a teleport (Deadmines' Moonbrook @@ -3048,7 +3048,7 @@ pub(crate) fn enter_sessionless_areatrigger( /// instance (`instance::resolve_or_create_instance`: own live binding → party's live instance → /// create; solo allowed; 5-player cap enforced here at trigger time) and teleports into it. Every /// NON-dungeon target keeps the byte-identical instance-0 path (`is_dungeon_map` false → the same -/// `teleport_player(..., 0, ...)` call as before) — the Deadmines EXIT trigger (map 36 → map 0) +/// `teleport_player(..., 0,...)` call as before), the Deadmines EXIT trigger (map 36 → map 0) /// rides that arm, correctly landing the leaver back in the open world. pub(crate) fn apply_enter_areatrigger(ctx: &ReducerContext, player_guid: u64, trigger_id: u32) { let teleport_target = ctx @@ -3081,7 +3081,7 @@ pub(crate) fn apply_enter_areatrigger(ctx: &ReducerContext, player_guid: u64, tr } // =========================================================================================== -// Timed quests (work-item 194) — expiry tick +// Timed quests, expiry tick // =========================================================================================== /// Is a timed quest EXPIRED? Pure: `deadline_micros == 0` (untimed) never expires; an already @@ -3092,7 +3092,7 @@ fn is_expired(now_micros: i64, deadline_micros: i64, rewarded: bool, failed: boo deadline_micros != 0 && !rewarded && !failed && now_micros >= deadline_micros } -// The timed-quest expiry tick (work-item 194): every `tick_creatures` cadence (0.5s, via the +// The timed-quest expiry tick : every `tick_creatures` cadence (0.5s, via the // `game_tick_pass!` marker — zero core edits needed elsewhere), scan the quest log for rows whose // deadline has passed and fail them (`failed = true`, `deadline_micros` cleared back to 0 — a failed // row's deadline is meaningless past this point). The gateway's `on_quest_update` relay diffs @@ -3114,10 +3114,10 @@ crate::game_tick_pass!(fn quest_timer_pass(ctx) { // transfer settles fails the quest exactly as it would have. It is also the better player // outcome — a character frozen on a shard-hop loading screen cannot act on a timer. let expired: Vec = log - .iter() - .filter(|q| is_expired(now, q.deadline_micros, q.rewarded, q.failed)) - .filter(|q| !crate::transfer::is_in_transit(ctx, q.character_guid)) - .collect(); +.iter() +.filter(|q| is_expired(now, q.deadline_micros, q.rewarded, q.failed)) +.filter(|q| !crate::transfer::is_in_transit(ctx, q.character_guid)) +.collect(); for mut cq in expired { cq.failed = true; cq.deadline_micros = 0; @@ -3126,7 +3126,7 @@ crate::game_tick_pass!(fn quest_timer_pass(ctx) { }); // =========================================================================================== -// Sharing (work-item 194) — CMSG_PUSHQUESTTOPARTY +// Sharing, CMSG_PUSHQUESTTOPARTY // =========================================================================================== /// The shared core behind [`push_quest_to_party`] and its debug twin `debug_push_quest`. @@ -3248,12 +3248,6 @@ pub(crate) fn debug_force_expire( Ok(()) } -// =========================================================================================== -// Player reducers — authorized via ctx.sender, delegate to the cores -// =========================================================================================== - -/// The abandon core, actor-explicit — the body [`abandon_quest`] used to inline, shared with -/// `gw::gw_abandon_quest`. pub(crate) fn apply_abandon_quest( ctx: &ReducerContext, player_guid: u64, @@ -3400,7 +3394,7 @@ mod tests { assert!(pick_choice_reward(&choices, u32::MAX, 42).is_err()); } - // ---- Timed quests (work-item 194): is_expired boundary tests ---- + // ---- Timed quests : is_expired boundary tests ---- #[test] fn untimed_deadline_never_expires() { diff --git a/module/src/realm_core.rs b/module/src/realm_core.rs index a6f5fc737..748b4ca4d 100644 --- a/module/src/realm_core.rs +++ b/module/src/realm_core.rs @@ -90,7 +90,7 @@ crate::character_owned!(not_transported, fn sweep_transfer_game_character_shard( /// Upsert the index entry for `character_guid`. Shared by [`set_character_shard`] (the gateway's /// write, on realm-core) and by `transfer::do_finish` (the module's write, in the SAME transaction -/// that releases the escrow — AC#3). +/// that releases the escrow). pub(crate) fn record_shard( ctx: &ReducerContext, character_guid: u64, diff --git a/module/src/region.rs b/module/src/region.rs index 1e79a07b4..6ffa52e59 100644 --- a/module/src/region.rs +++ b/module/src/region.rs @@ -1,29 +1,5 @@ -//! Regions: the cell→region→shard hierarchy as DATA (spec). -//! -//! Two tables, one job each, and **no game logic in this file reads either of them**: -//! -//! - [`MapRegion`] — the baked region definitions (`region_of(map_id, cell)`), content data loaded -//! by [`import_map_regions`] from the seam-menu format in `docs/region-sharding.md`. Imported -//! alongside the world ETL that bakes `grid_x`/`grid_y`, because it is the same kind of thing: a -//! pre-computed spatial fact, not something gameplay derives at runtime. -//! - [`RegionAssignment`] — `{ map_id, region_id, shard, epoch }`, the epoch-versioned ops table -//! that says which DATABASE owns a region. It is authoritative **on realm-core** (the same wasm -//! published under another database name — see `realm_core.rs` for why that is the whole -//! mechanism), and the gateway subscribes to it. -//! -//! # Why the module never reads a shard id -//! -//! `shard` is a database name: a GATEWAY routing fact. The moment a reducer branches on it, the -//! module stops being relocatable and the world stops being shardable — so `tripwires.rs`'s -//! `partition_discipline_tripwire` fails the build if the accessor or the `.shard` field is touched -//! anywhere but this file. The reducer below writes it; nothing reads it. -//! -//! # What this ticket deliberately does NOT do -//! -//! Nothing here migrates anything. With every region of a map assigned to one shard — or with no -//! assignment rows at all, the default — routing is a strict no-op and the seams are dormant. -//! View-merge, warm handoff, relayed intents and live migration are later tickets, gated on the -//! Benchmark showing a tuned writer actually saturating. +//! Region definitions and epoch-versioned assignments. +//! The Module writes routing data for the Gateway and never branches on a Shard name. use lyracore_shared::region::RegionMap; use spacetimedb::{reducer, table, ReducerContext, Table}; @@ -57,11 +33,6 @@ pub struct MapRegion { /// `region_assignment { map_id, region_id, shard, epoch }` — which database owns a region, and at /// which epoch. Authoritative on realm-core; inert everywhere else (nothing writes it there). /// -/// **Epoch semantics.** `epoch` is a monotonic version stamp per `(map_id, region_id)`: a flip must -/// carry a STRICTLY higher epoch than the row it replaces, so a retried or reordered operator call -/// can never resurrect a superseded assignment. A flip re-routes **new entrants only** — the gateway -/// resolves a region→shard exactly once per world entry — and this ticket moves no resident. -/// /// PRIVATE: this table names the realm's databases. Never hand a client the topology. #[table(accessor = game_region_assignment)] pub struct RegionAssignment { @@ -79,7 +50,7 @@ pub struct RegionAssignment { pub updated_micros: i64, } -/// Replace the realm's whole seam menu with `packed` (the `docs/region-sharding.md` format). +/// Replace the realm's whole seam menu with `packed` (the `RegionMap::parse` format). /// Operator-only, like every other importer. /// /// Rejects the WHOLE payload if any line is malformed or breaks the geometry rules (overlap, the diff --git a/module/src/reputation.rs b/module/src/reputation.rs index 17646edfb..297c99e9b 100644 --- a/module/src/reputation.rs +++ b/module/src/reputation.rs @@ -288,7 +288,7 @@ mod rep_tests { assert_eq!(reputation_discount_pct(0), 0); // Neutral → full price assert_eq!(reputation_discount_pct(-10000), 0); // hostile → no discount (never a surcharge) assert_eq!(reputation_discount_pct(3000), 5); // Friendly → 5% - assert_eq!(reputation_discount_pct(9000), 10); // Honored → 10% (the work-item's headline case) + assert_eq!(reputation_discount_pct(9000), 10); assert_eq!(reputation_discount_pct(21000), 15); // Revered → 15% assert_eq!(reputation_discount_pct(42000), 20); // Exalted → 20% } diff --git a/module/src/runtime_script.rs b/module/src/runtime_script.rs index 5e5540632..06173710d 100644 --- a/module/src/runtime_script.rs +++ b/module/src/runtime_script.rs @@ -1,66 +1,4 @@ -//! The Runtime Script Host: the contained Lua interpreter a Runtime Script runs inside. -//! -//! One [`RuntimeScriptHost`] owns one embedded piccolo interpreter and a compiler cache. The cache holds -//! compiled chunks only, keyed by a hash of the source, so it can never hand back stale code for -//! an edited script and it holds no Lua state between invocations. -//! -//! Every invocation gets: -//! -//! * a fresh environment table with its own standard-library tables, so nothing a script assigns -//! survives it; -//! * a [`FUEL_BUDGET_PER_INVOCATION`] of metered interpreter work, plus -//! [`MAX_STEPS_PER_INVOCATION`] as the -//! stall guard for the case where a step burns no fuel at all; -//! * a staging buffer. A host operation a script calls does not touch the world; it appends a -//! [`StagedEffect`]. Only a fully successful invocation returns [`StagedEffects`], which the -//! caller commits through core gameplay operations. A syntax, runtime, or fuel failure returns a -//! [`ScriptDiagnostic`] instead and every effect staged by that invocation is dropped with it. -//! -//! [`run_event`] is the failure boundary: it invokes each Runtime Script in turn, commits the ones -//! that succeeded, and collects a bounded diagnostic for the ones that did not. One bad script -//! never stops the next one, and never stops the core work that follows. -//! -//! # The curated gameplay surface -//! -//! An Invocation sees one global per host operation and one `event` table, and nothing else of the -//! Module: -//! -//! ```lua -//! event.name -- the event label, a string -//! event.actor -- the Entity Handle that caused the event, or nil -//! event.target -- the Entity Handle the event acted on, or nil -//! -//! -- An Entity Handle's readable fields, snapshotted when the Invocation started: -//! e.name, e.is_player, e.level, e.health, e.max_health, e.map_id, e.x, e.y, e.z -//! -//! heal(entity, amount) -- stage a heal, crediting heal-threat to event.actor -//! send_chat(player, text) -- stage a System Message to one online player -//! grant_xp(player, amount)-- stage an experience grant -//! -//! return 42 -- the Script Answer: a number the asking caller reads back -//! ``` -//! -//! A chunk that returns a number answers the caller that asked; returning nothing, or anything that -//! is not a number, answers nothing. Only [`ask_event`] reads an answer — [`run_event`] discards it, -//! because a core hook event has no caller waiting on one. -//! -//! An Entity Handle is opaque: a script cannot read a guid out of it and cannot mint one, so the -//! only entities a Runtime Script can act on are the ones the Host resolved for that Invocation. -//! Every readable field is a snapshot taken before the script ran; writing to one changes nothing. -//! -//! A host operation called with a missing entity, the wrong type, an out-of-range amount, or past -//! the staging cap raises a Lua error naming the call and the fault. That fails the Invocation — -//! a [`ScriptDiagnostic`], no staged effect committed — and never panics out of the reducer. -//! -//! # What an Invocation is allowed -//! -//! The environment is built from [`ALLOWED_GLOBALS`] and [`ALLOWED_LIBRARY_MEMBERS`], name by name, -//! into tables made for that Invocation. A name outside those lists is nil inside a Runtime Script, -//! and a write to an allowed library table is invisible to the next Invocation. -//! -//! What this host deliberately does NOT do: durable script storage, event bindings, damage, items, -//! spawning, scheduling, any query surface over the database, or any Lua state that outlives an -//! Invocation. +//! Runtime Script Host. See `docs/runtime-script-host.md` for the contract and rationale. use std::cell::RefCell; use std::collections::HashMap; @@ -1560,7 +1498,7 @@ if #roster > 0 then grant_xp(event.actor, 25) end if type(a) ~= \"userdata\" then error(\"a handle must not be a table\") end\n\ grant_xp(a, 1)", ) - .expect("a handle carries no identifier"), +.expect("a handle carries no identifier"), [xp(PLAYER_GUID, 1)] ); } @@ -2203,12 +2141,6 @@ if #roster > 0 then grant_xp(event.actor, 25) end } } -/// piccolo reaches `getrandom` twice on `wasm32-unknown-unknown` — 0.2 through `rand`, when -/// `Lua::core()` seeds `math.random`, and 0.3 through `ahash`, when the first Lua table is built. -/// Neither generation has a backend on that target, and the JS backend the Runtime Script -/// Prototype used to -/// get a compile emits `wasm-bindgen` imports a SpacetimeDB host cannot resolve. So the Module -/// supplies its own, selected by `getrandom_backend="custom"` in `.cargo/config.toml`. /// /// It is a FIXED stream on purpose. The Module is replicated and replayed; a random hash seed /// would let table iteration order differ between two runs of the same reducer sequence. Nothing diff --git a/module/src/seed.rs b/module/src/seed.rs index d8949479b..8dafcd2c2 100644 --- a/module/src/seed.rs +++ b/module/src/seed.rs @@ -1,10 +1,6 @@ -//! The `init` lifecycle reducer — the single entrypoint that populates a fresh database. `init` -//! itself is a four-line dispatcher over four banner-stratum fns, each a straight -//! extraction of what used to be one ~1,600-line function (a reader still sees the whole seed by -//! reading top to bottom — the split is fn boundaries, not a reorder): //! //! 1. **`seed_production_core`**: realm, server config, the human-warrior start position, the -//! fallback graveyard/graveyard-zone rows (work-item 209), the TEST account + pre-seeded +//! fallback graveyard/graveyard-zone rows , the TEST account + pre-seeded //! character (with its starter spellbook/action-bar kit), and the EventAI on-aggro barks. Every //! fresh database needs this regardless of whether it will ever host a real import. //! 2. **`seed_map0_demo_content`** (the in-body `DECISION` comment has the full @@ -51,11 +47,6 @@ use crate::{game_alpha_test_tools_enrollment, AlphaTestToolsEnrollment}; #[reducer(init)] pub fn init(ctx: &ReducerContext) { - // Four banner strata (split these out of what used to be one ~1,600-line fn — see this - // file's header for what each one seeds and why the split points fall where they do). Order - // matters: later strata reference nothing from earlier ones (each re-derives its own `hw` - // alias), but the production core must exist before anything reads `game_config`/`game_realm`, - // and the scheduler must arm last so nothing fires against a half-seeded database. seed_production_core(ctx); seed_map0_demo_content(ctx); seed_spell_registry(ctx); @@ -65,7 +56,7 @@ pub fn init(ctx: &ReducerContext) { /// Stratum 1 — the production core every fresh database needs regardless of whether it will ever /// host a real import: realm, server config, the human-warrior start position, the fallback -/// graveyard/graveyard-zone rows (work-item 209), the TEST account + pre-seeded character (with its +/// graveyard/graveyard-zone rows , the TEST account + pre-seeded character (with its /// starter spellbook/action-bar kit), and the EventAI on-aggro barks. fn seed_production_core(ctx: &ReducerContext) { use constants::start_human_warrior as hw; @@ -116,7 +107,7 @@ fn seed_production_core(ctx: &ReducerContext) { display_id: 49, }); - // Graveyard fallback seed (work-item 209): the SAME five Elwynn/Westfall graveyards + // Graveyard fallback seed : the SAME five Elwynn/Westfall graveyards // `world::graveyard`'s hardcoded consts carry, ALSO row-seeded into `game_graveyard` + // `game_graveyard_zone` so a fresh unimported DB and the live `graveyard::resolve_graveyard` // path agree exactly — mirrors the `game_start_position` precedent (init seeds; the importer's @@ -218,12 +209,12 @@ fn seed_production_core(ctx: &ReducerContext) { health: 0, // sentinel: spawn at full health power: 0, // sentinel: spawn at starting power respec_count: 0, - death_expire_micros: 0, // never died - pending_instance_id: 0, // open world - gm_level: 3, // work-item 223: the seeded Tester is playtest-GM by default - pending_ghost: false, // alive (work-item 226) - resting: false, // 196 - rested_since_micros: 0, // 196 + death_expire_micros: 0, // never died + pending_instance_id: 0, // open world + gm_level: 3, + pending_ghost: false, + resting: false, // 196 + rested_since_micros: 0, // 196 pending_godmode: false, // 289: GM playtest carry — off until `.god` + a map change pending_run_speed_mult_bp: crate::world::RUN_SPEED_BP_1X, // 289: 1× bank_bag_slots: 0, @@ -231,7 +222,7 @@ fn seed_production_core(ctx: &ReducerContext) { // The seeded character goes through the same creation-time kit grant as `create_character` // (rows restamp to the real owner identity at establish_session, like its other owned rows). crate::spell::spellbook::grant_createinfo_spells(ctx, 1, Identity::ZERO, hw::RACE, hw::CLASS); - // Action-bar rows (work-item 212) — same no-op-pre-import grant `create_character` calls. + // Action-bar rows, same no-op-pre-import grant `create_character` calls. crate::action_bar::grant_createinfo_actions(ctx, 1, Identity::ZERO, hw::RACE, hw::CLASS); // Creature EventAI (193): the fixture on-aggro barks (Kobold/Defias/Hogger). @@ -598,8 +589,8 @@ fn seed_map0_demo_content(ctx: &ReducerContext) { gather_skill_line: 0, // not a gather node respawn_secs: 0, // n/a (a CHEST has no respawn timer); 0 ⇒ the 3-min fallback if ever used gather_gray: 0, // n/a (not a gather node) — the always-skill sentinel - lock_id: 0, // work-item 211: unlocked (seed/demo chest) - size: 0.0, // no dump size — the gateway renders this at 1.0 + lock_id: 0, + size: 0.0, // no dump size, the gateway renders this at 1.0 }); ctx.db.game_gameobject().insert(GameObject { guid: GO_HIGH | 1, @@ -633,8 +624,8 @@ fn seed_map0_demo_content(ctx: &ReducerContext) { gather_skill_line: 0, // not a gather node respawn_secs: 0, // n/a (a GOOBER has no respawn timer) gather_gray: 0, // n/a (not a gather node) - lock_id: 0, // work-item 211: unlocked (seed/demo goober) - size: 0.0, // no dump size — the gateway renders this at 1.0 + lock_id: 0, + size: 0.0, // no dump size, the gateway renders this at 1.0 }); ctx.db.game_gameobject().insert(GameObject { guid: GO_HIGH | 2, @@ -674,8 +665,8 @@ fn seed_map0_demo_content(ctx: &ReducerContext) { gather_skill_line: crate::skill::skill_line::MINING, // 186 respawn_secs: 0, // 0 ⇒ the 3-min RESPAWN_WINDOW_MICROS fallback gather_gray: 0, // 0 ⇒ the always-skill sentinel (deterministic +1 every gather) - lock_id: 0, // work-item 211: gather nodes don't source a lockId this slice - size: 0.0, // no dump size — the ETL carries the real one + lock_id: 0, + size: 0.0, // no dump size, the ETL carries the real one }); ctx.db.game_gameobject().insert(GameObject { guid: GO_HIGH | 3, @@ -709,8 +700,8 @@ fn seed_map0_demo_content(ctx: &ReducerContext) { gather_skill_line: crate::skill::skill_line::HERBALISM, // 182 respawn_secs: 0, // 0 ⇒ the 3-min RESPAWN_WINDOW_MICROS fallback gather_gray: 0, // 0 ⇒ the always-skill sentinel (deterministic +1 every gather) - lock_id: 0, // work-item 211: gather nodes don't source a lockId this slice - size: 0.0, // no dump size — the ETL carries the real one + lock_id: 0, + size: 0.0, // no dump size, the ETL carries the real one }); ctx.db.game_gameobject().insert(GameObject { guid: GO_HIGH | 4, @@ -744,7 +735,7 @@ fn seed_map0_demo_content(ctx: &ReducerContext) { // REAL entries 1731/1732 (NOT the synthetic 50102 Copper Vein above): both type 25 GATHER, line 186 // MINING. INIT-ONLY (the live pool/member rows are made here + arm); a re-import (`DELETE FROM // game_gameobject_pool WHERE pool_id > 0`) wipes this pool, so on the live/imported DB it is re-seeded - // post-import via `debug_setup_gather_pool 2 1 true ...`. pool_id 2 + // post-import via `debug_setup_gather_pool 2 1 true...`. pool_id 2 // is distinct from the debug pool (1) and the importer's roaming base (1000). Ensure the two tier // templates exist first (idempotent — the bare seed lacks them; the ETL also loads them). for (e, name, item, req) in [ @@ -764,8 +755,8 @@ fn seed_map0_demo_content(ctx: &ReducerContext) { gather_skill_line: crate::skill::skill_line::MINING, // 186 respawn_secs: 300, // real vanilla mining-node window (5 min); reroll fires at timer-fire gather_gray: 0, // always-skill sentinel (deterministic +1 every gather) - lock_id: 0, // work-item 211: gather nodes don't source a lockId this slice - size: 0.0, // no dump size — the gateway renders this at 1.0 + lock_id: 0, + size: 0.0, // no dump size, the gateway renders this at 1.0 }); } } @@ -1003,7 +994,7 @@ fn seed_spell_registry(ctx: &ReducerContext) { spell(50050, "Test Conjure", 0, 0, 0, 0, 0, 1, 0, false, 0); effect(50050, 0, 0x07, 2, 0, 0, 5349, 8); // E_CREATE_ITEM: 2× item 5349 (Conjured Muffin), self - // Craft RECIPES are no longer seeded (work-item 282): they import from the real Spell.dbc with real + // Craft RECIPES are no longer seeded : they import from the real Spell.dbc with real // reagents (game_spell_reagent) + skill-up bands (game_skill_ability), offered by the real in-box // trainers. The old synthetic recipe spells here (2538 — which was even FABRICATED as "Roasted Boar // Meat" when the real 2538 is "Charred Wolf Meat"; 50071; 50090-50097) are gone. Crafted-item ON-USE @@ -1245,20 +1236,13 @@ fn seed_spell_registry(ctx: &ReducerContext) { // Test Fear (50022) — the FEAR crowd-control: ONE A_CONTROL (0xB0) effect whose p0 names the MECHANIC // M_FEAR (3, p0_kind 3 P_MECHANIC), targeting an ENEMY. A feared unit cannot ACT (no swing/cast — the - // ACTION gates fold fear in) and is force-walked AWAY from the caster by the fear-flee pass each tick + // TION gates fold fear in) and is force-walked AWAY from the caster by the fear-flee pass each tick // ("flees in terror"); it stays engaged so it resumes attacking when the aura ends. SHORT 8s duration // (≈2 ticks, like Warlock Fear) — bounded so the test subject doesn't run off the map. aura_interrupt // stays 0: base fear does NOT break on damage (unlike polymorph). is_negative true. spell(50022, "Test Fear", 0, 0, 0, 30, 8000, 1, 0, true, 1); effect(50022, 0, 0xB0, 0, 0, 1, 3, 3); // A_CONTROL, p0 = M_FEAR (3), p0_kind = P_MECHANIC (3), enemy - // Test Poly (50023, work-item 192) — the POLYMORPH crowd-control: ONE A_CONTROL (0xB0) effect whose p0 - // names the MECHANIC M_POLY (4, p0_kind 3 P_MECHANIC), targeting an ENEMY. `is_incapacitated` gates - // stun/poly identically (no act, no move) — this fixture exists so CC DIMINISHING RETURNS has a real, - // debug-castable spell to drive the live-probe runbook (two poly casts on a player target 15s apart or - // less land at 100/50/25/0%; the same double-cast on a CREATURE target is always full duration — see - // `spell::stacking`'s DR resolver + the work-item's completion note). 10s duration matches the - // pure-fn DR test vector's base duration exactly (10s → 5s → 2.5s at levels 1/2/3). is_negative true. spell(50023, "Test Poly", 0, 0, 0, 30, 10000, 1, 0, true, 1); effect(50023, 0, 0xB0, 0, 0, 1, 4, 3); // A_CONTROL, p0 = M_POLY (4), p0_kind = P_MECHANIC (3), enemy @@ -1325,7 +1309,7 @@ fn seed_spell_registry(ctx: &ReducerContext) { // `debug_seed_talents` (init does NOT re-run on an auto-migrate publish, so the live DB re-seeds via that). crate::talent::seed_talents(ctx); - // Stacking-group starter set (work-item 192) — hand-authored until 102's cmangos `spell_group` SQL + // Stacking-group starter set, hand-authored until 102's cmangos `spell_group` SQL // dump lands wholesale. Idempotent + shared with `debug_repair_after_publish`, which is how an // already-migrated development database picks up reconciled rows (init does NOT re-run). seed_spell_groups(ctx); @@ -1560,7 +1544,7 @@ fn seed_scheduler_arming(ctx: &ReducerContext) { scheduled_at: ScheduleAt::Interval(TimeDuration::from_micros(EVENT_TTL_MICROS)), }); - // Schedule the instance reaper (work-item 190 slice 3) every 60s — minutes-scale occupancy + // Schedule the instance reaper every 60s, minutes-scale occupancy // stamping + the 30min-empty / reset-requested reap. A live DB (auto-migrate publish) never // re-runs init, so re-arm there via `debug_rearm_instance_reaper` (the // `debug_rearm_creature_tick` precedent). @@ -1577,7 +1561,7 @@ fn seed_scheduler_arming(ctx: &ReducerContext) { // every tick (smooth, mangos-cadence motion); the O(N) sensing passes only every 8th tick (~4s) — // see `tick_creatures`. A live DB (auto-migrate publish) keeps its old interval, so re-arm via the // `debug_rearm_creature_tick` reducer (init does NOT re-run on a plain publish). - // Work-item 229: this seeded row is the GLOBAL/CATCH-ALL ticker (`GLOBAL_TICK_INSTANCE`) — it + // this seeded row is the GLOBAL/CATCH-ALL ticker (`GLOBAL_TICK_INSTANCE`), it // covers instance 0 AND every instance without a dedicated row of its own (load-bearing; never // delete it). Dedicated per-instance rows are inserted by 190 slice 2's create_instance (or, until // then, `debug_arm_instance_tick`). @@ -1705,7 +1689,7 @@ pub(crate) fn seed_createinfo_spells(ctx: &ReducerContext) { } } -/// The stacking-group starter set (work-item 192) — hand-authored ahead of 102's cmangos `spell_group`/ +/// The stacking-group starter set, hand-authored ahead of 102's cmangos `spell_group`/ /// `spell_group_stack_rules` SQL dump, which will fill `game_spell_group`/`game_spell_group_rule` /// wholesale and supersede this. Idempotent (only-if-empty, mirroring `seed_createinfo_spells`); shared by /// `init` and `debug_repair_after_publish` (init does NOT re-run on an auto-migrate publish). It diff --git a/module/src/seed/fixtures.rs b/module/src/seed/fixtures.rs index 317e7e782..8aa3b8879 100644 --- a/module/src/seed/fixtures.rs +++ b/module/src/seed/fixtures.rs @@ -572,7 +572,7 @@ pub(crate) fn base_item(entry: u32, name: &str) -> ItemTemplate { /// already-migrated dev DB (where `init` did not re-run). /// /// Weakened Soul (REAL vanilla id 6788) is the hardcoded Power Word: Shield lockout debuff. Its real -/// Spell.dbc shape (CONFIRMED via a DBC dry-run for work-item 122 — this is NOT the effectless marker +/// Spell.dbc shape (CONFIRMED via a DBC dry-run for, this is NOT the effectless marker /// earlier believed) is a single `A_IMMUNITY` (0xB1) aura with MiscValue 19 (MECHANIC_SHIELD): vanilla's /// actual "immune to the shield mechanic" (i.e. can't be re-shielded) effect. 15s duration, holy school /// (school_mask 2), dispel_type 0 — mirroring the importer's DBC output so a seed-only dev DB matches a @@ -652,7 +652,7 @@ pub(crate) fn seed_pw_shield_fixture(ctx: &ReducerContext) { ); } -/// Hand-seed the Soul Shard item template (real vanilla item 6265). The .import ETL +/// Hand-seed the Soul Shard item template (real vanilla item 6265). The.import ETL /// doesn't reliably carry it, so — mirroring `seed_pw_shield_fixture`'s precedent for a mechanic whose /// live-DBC row isn't available in every dev environment — it's authored here. A plain, non-equippable, /// non-sellable trade good (vanilla: Soul Shard cannot be sold to a vendor; `sell_price: 0` encodes @@ -1120,10 +1120,6 @@ pub(crate) const FIXTURE_JERKY: u32 = 5090052; /// The reserved reusable MOUNT item — same reserved-entry rationale as the two above. pub(crate) const FIXTURE_REINS: u32 = 5090054; -/// Insert the two reserved fixture item templates (insert-if-absent) — built from the same -/// `tempered_blade_template`/`tough_jerky_template` constructors the mock-seed's Tempered Blade -/// (50) / Tough Jerky (52) use, under the reserved entries above (this used to be a -/// hand-copied literal that could drift from the mock-seed's). fn seed_fixture_items(ctx: &ReducerContext) { let items = ctx.db.game_item_template(); if items.entry().find(FIXTURE_BLADE).is_none() { @@ -1329,7 +1325,7 @@ pub(crate) fn seed_fixture_catalogue(ctx: &ReducerContext) { /// - vendor/repairer NPC template 51004 selling Tempered Blade (50) + Tough Jerky (52). /// - trainer offering on the seeded Profession Trainer (51001): Lesser Heal (2050, a seeded 1.5s /// heal) for 100c at level 1 — the train-and-cast scenario's purchase. -/// - Weapon Master NPC template 51005 ("Woo Ping", work-item 202): a second GOSSIP|TRAINER creature +/// - Weapon Master NPC template 51005 ("Woo Ping"): a second GOSSIP|TRAINER creature /// (mirrors the 51004 vendor block) offering 1H Axe (skill line 44, marker 50130, required_level 1, /// 100c) and Polearm (skill line 229, marker 50131, required_level 60, 100c — the level-refusal /// fixture). Both rows carry `learn_skill_line` set to a COMBAT line, so `apply_trainer_buy` routes @@ -1431,13 +1427,6 @@ pub(crate) fn seed_scenario_fixtures(ctx: &ReducerContext) { }); } - // 060/187 recurring trap: the world ETL truncates game_creature_template and reloads from the - // dump — the INIT-seeded fixture templates (Test Wolf 51000, Profession Trainer 51001) vanish - // on every re-import, breaking the wire scenarios until someone reseeds by hand. Re-seed them - // HERE (this reducer is the operator's idempotent post-import fixture restore) from the SAME - // canonical constructors `seed::init` uses (this used to be a hand-copied literal that - // drifted — Profession Trainer was level 10/100hp/"Fixture" here vs level 30/1500hp/"Cooking & - // Skinning" in init, and Test Wolf's money_min/max disagreed too). let templates = ctx.db.game_creature_template(); if templates.entry().find(WOLF).is_none() { templates.insert(test_wolf_template()); @@ -1612,7 +1601,7 @@ pub(crate) fn seed_scenario_fixtures(ctx: &ReducerContext) { }); } - // --- WEAPON MASTER (work-item 202): "Woo Ping" (51005) sells weapon proficiencies for gold — + // --- WEAPON MASTER : "Woo Ping" (51005) sells weapon proficiencies for gold , // the vanilla weapon-master shape (a trainer-list row whose `learn_skill_line` names a weapon line // instead of a spell/profession). Mirrors the 51004 vendor block: GOSSIP|TRAINER, faction 35 // (FRIENDLY, never a kill target). @@ -1745,7 +1734,7 @@ pub(crate) fn seed_scenario_fixtures(ctx: &ReducerContext) { /// /// THE DISCIPLINE: delete-then-insert is idempotent BY CONSTRUCTION — calling it twice with /// the same row is a no-op, and calling it with a changed shape self-corrects the row in place. So -/// every call site below calls this UNCONDITIONALLY, with no `if find(id).is_none() { ... }` guard +/// every call site below calls this UNCONDITIONALLY, with no `if find(id).is_none() {... }` guard /// around it. A guard doesn't just add noise: it makes the delete dead code (the branch that would /// run it never fires when the row already exists), which silently turns "re-seed self-corrects" /// back into "re-seed only fills gaps" — exactly the bug class this fn exists to prevent. If a diff --git a/module/src/skill.rs b/module/src/skill.rs index fdba95600..41d75cd3a 100644 --- a/module/src/skill.rs +++ b/module/src/skill.rs @@ -35,7 +35,7 @@ pub const SKILL_PER_LEVEL: u32 = 5; /// read with no remap — a local enum would force a translation layer at the descriptor boundary). Only /// the lines combat/professions can actually produce are defined; ranged lines come with their features. /// -/// DEPRECATION POINTER (work-item 208): `game_skill_line` (`skilldata.rs`), loaded from the operator's +/// DEPRECATION POINTER : `game_skill_line` (`skilldata.rs`), loaded from the operator's /// real `SkillLine.dbc` by the importer, is now the AUTHORITATIVE source for "what skill lines exist" — /// it carries every vanilla line (~135), not just the ~17 hand-picked here. These consts stay because /// the wire protocol is still keyed on the same verbatim ids (no remap needed either way), but treat @@ -74,11 +74,7 @@ pub mod skill_line { pub const ENGINEERING: u32 = 202; pub const ENCHANTING: u32 = 333; pub const FISHING: u32 = 356; - // Work-item 211 (Lock.dbc): the SkillLine a `ty==LocktypeReference` Lock.dbc index resolves to when - // its LockType.dbc id is 1 (Lockpicking). Not otherwise consumed this slice (no lockpicking reducer - // yet — `game_lock` is data-only until work-item 119) but kept alongside the other verbatim SkillLine - // IDs so the importer's LockType→SkillLine map (`importer/src/dbc.rs`) has ONE source of truth to - // cite in its doc comment rather than a bare magic number. + pub const LOCKPICKING: u32 = 633; /// Riding — the mount line. Neither a profession nor a combat line: its rank is a TRAINED TIER /// (Apprentice 75, Journeyman 150) granted whole by a `trainer_type::MOUNTS` trainer, never climbed @@ -87,10 +83,6 @@ pub mod skill_line { pub const RIDING: u32 = lyracore_shared::trainer::RIDING_SKILL_LINE; } -/// Is `line` one of the 13 player professions (primary/secondary craft + gather lines)? Used to -/// tell a CRAFT recipe's skill-line ability apart from a combat/class spell ability when the recipe -/// gate reads `game_skill_ability` (282) — a reagent-consuming BUFF (Arcane Intellect) pairs with a -/// magic-school line, not a profession, so it never counts as a craft. pub fn is_profession_line(line: u32) -> bool { use skill_line::*; matches!( @@ -201,10 +193,6 @@ const CATCH_UP_LOW_LEVEL_LIFT: f64 = 12.0; /// The most intellect can add to a WEAPON line's per-hit chance (as a fraction, so +10 points). const INTELLECT_ASSIST_MAX: f64 = 0.10; -/// `(level ceiling, reference level, intellect worth the full assist)` — the assist is scaled both -/// by how much intellect the character has against the band's reference amount and by how far below -/// the band's reference level they are. A vanilla realm caps at 60 and so only ever reads the first -/// band; the rest keep the function total for any level a debug lever might set. const INTELLECT_ASSIST_BANDS: [(u32, f64, f64); 4] = [ (60, 60.0, 750.0), (70, 70.0, 1500.0), @@ -516,7 +504,7 @@ pub fn gain_defense_skill(ctx: &ReducerContext, target: &WorldEntity) { /// The COMBAT (weapon + Defense) skill lines — the set whose `max_rank` tracks `level*5`, as opposed to /// profession lines (Cooking, Mining, …) whose cap is an independent apprentice/journeyman/…/artisan tier /// set by `learn_profession`. Used by `raise_combat_caps` to know which rows to lift on a ding/login and -/// never touch a profession row's cap. `pub(crate)` (weapon masters, work-item 202): `trainer::apply_trainer_buy` +/// never touch a profession row's cap. `pub(crate)` (weapon masters): `trainer::apply_trainer_buy` /// forks its profession-learn branch on this same taxonomy — a weapon-master offering (`learn_skill_line` set /// to a weapon line) needs a LEVEL-derived cap, not the offering's static tier column. pub(crate) fn is_combat_skill_line(line: u32) -> bool { @@ -583,7 +571,7 @@ pub(crate) fn learn_riding(ctx: &ReducerContext, guid: u64, owner: Identity, ran /// character whose caps were frozen at first-login level — so a relog alone un-sticks a stale character, /// not just future dings). [entity] /// -/// Lockpicking (633, work-item 172) also tracks `level*5` in vanilla but isn't part of +/// Lockpicking (633) also tracks `level*5` in vanilla but isn't part of /// `is_combat_skill_line` — that predicate is shared with `trainer::apply_trainer_buy`'s weapon-master /// fork, and Lockpicking is never a trainer offering (it's granted by learning Pick Lock, see /// `grant_lockpicking_on_learn`), so it's checked here as a second, dedicated clause instead of joining @@ -842,14 +830,14 @@ pub(crate) fn grant_autolearn_abilities( } } -/// Pick Lock (Rogue) — the spell whose learn grants the Lockpicking (633) skill line (work-item 119). +/// Pick Lock (Rogue), the spell whose learn grants the Lockpicking (633) skill line. /// Kept as ONE named constant (the isolated place a spell id is named, like the importer's name rescues) /// because the skill GRANT keys on the id: the gateway routes the CAST by the E_OPEN_LOCK effect kind, /// but a fresh Rogue must already own the 633 row when their first pick_lock lands — `apply_pick_lock` /// reads `game_player_skill(633)`, and an absent row reads skill 0 and refuses every lock. pub(crate) const PICK_LOCK_SPELL_ID: u32 = 1804; -/// Grant the Lockpicking (633) skill line when a character learns Pick Lock (work-item 119) — insert +/// Grant the Lockpicking (633) skill line when a character learns Pick Lock, insert /// `1 / level*5` if absent (mirrors `learn_profession`'s NEW-row insert, but LEVEL-capped like a weapon /// line: vanilla Lockpicking maxes at level×5, not the 75 apprentice tier). Idempotent — a re-learn /// never resets a climbed skill. A no-op for every other spell. Called from the single `learn_spell` @@ -988,18 +976,12 @@ pub fn debug_learn_profession_from_trainer( crate::trainer::apply_trainer_buy(ctx, character_guid, trainer.guid, spell_id) } -/// The synthetic "weapon-learn spell" marker ids the seeded Weapon Master (51005) offers (work-item -/// 202) — same convention as the profession markers above: NEVER resolved as spells (no `game_spell` -/// header), they exist ONLY as a `game_trainer_spell.spell_id` whose `learn_skill_line` names a COMBAT -/// line (`is_combat_skill_line`), routing `trainer::apply_trainer_buy` onto the WEAPON fork (level-derived -/// cap + presence-known) instead of the profession fork (static-tier cap). Picked just past the -/// profession-breadth batch's high-water mark (50124) to avoid colliding with any seeded marker/recipe id. #[cfg(feature = "debug_reducers")] const LEARN_AXE_1H_SPELL_ID: u32 = 50130; // -> learn_skill_line = AXE_1H (44), required_level 1 (the buy-succeeds fixture) #[cfg(feature = "debug_reducers")] const LEARN_POLEARM_SPELL_ID: u32 = 50131; // -> learn_skill_line = POLEARM (229), required_level 60 (the level-refusal fixture) -/// DRIVE the real trainer-buy branch for a WEAPON PROFICIENCY (work-item 202) without the trainer-window +/// DRIVE the real trainer-buy branch for a WEAPON PROFICIENCY without the trainer-window /// UI — the weapon-master twin of `debug_learn_profession_from_trainer` above, same shape: the requested /// `skill_line` (Daggers/1H Axe/Polearm/…) maps to its marker spell id, the nearest live TRAINER creature on /// the learner's map is resolved SERVER-SIDE (mirroring `debug_skin_nearest`'s nearest-search — the @@ -1110,7 +1092,7 @@ pub fn debug_learn_riding_from_trainer( /// crossbow fall to UNARMED in `weapon_subclass_to_skill_line` and are therefore NOT listed here). /// Returns an empty slice for unknown classes (e.g. creatures). /// -/// DEPRECATION POINTER (work-item 208): this hand-authored per-class table could in principle be +/// DEPRECATION POINTER : this hand-authored per-class table could in principle be /// derived from `game_skill_availability` (`SkillRaceClassInfo.dbc`'s `class_mask`, imported by /// `skilldata.rs`) joined against `skill_line::*`'s known combat lines — that data-driven replacement /// is a follow-up, not done here (this function's behavior is UNCHANGED by this item). @@ -1547,7 +1529,7 @@ mod tests { assert_eq!(skill_diff(300, 315), 0); // higher weapon skill floors at 0 } - /// The combat/profession taxonomy `apply_trainer_buy` forks the weapon-master branch on (work-item 202): + /// The combat/profession taxonomy `apply_trainer_buy` forks the weapon-master branch on : /// a weapon line (DAGGER) is combat (level-capped); a profession line (COOKING) is not (static-tier capped). #[test] fn is_combat_skill_line_true_for_dagger_false_for_cooking() { diff --git a/module/src/skilldata.rs b/module/src/skilldata.rs index 4a760a24d..b290ee8f3 100644 --- a/module/src/skilldata.rs +++ b/module/src/skilldata.rs @@ -1,24 +1,5 @@ -//! Skill-fabric static data: `SkillLine.dbc` (the skill lines themselves — weapon/defense lines + -//! the 12 professions + a long tail of NPC-only/deprecated lines), `SkillLineAbility.dbc` (which -//! spell each line teaches/grants, at which skill threshold, and the green/gray skill-up difficulty -//! band for recipes), and `SkillRaceClassInfo.dbc` (which lines a given race/class combo can actually -//! train, and at what character level). Loaded by the importer's `--dbc` mode (see -//! `importer/src/dbc.rs`); no Timestamp columns, so every table here is plain SQL-loadable -//! (`spacetime sql` clear+reload, no reducer needed — same convention as `game_faction_template`/ -//! `game_race_info` in `faction.rs`/`config.rs`). -//! -//! SCOPE: this is the DATA half of work-item 208 — the import + tables + the ONE wired consumer -//! (profession trainer tier caps, resolved in `importer/src/main.rs`'s `PROFESSION_LEARN` + -//! `assert_profession_tier_parity`, sourced from `SkillRaceClassInfo.dbc`'s `skill_tier` foreign key -//! into `SkillTiers.dbc` — NOT from `SkillLine.dbc` itself, correcting the original item spec). The -//! remaining consumers each ride their own follow-up item: recipe skill-up ranges from -//! `game_skill_ability.gray`/`.green` (replacing the sentinel-band shortcut `skill::skillup_chance_bp`'s -//! callers currently use), weapon-master availability from `game_skill_availability` (work-item 202's -//! data half), and auto-learned spells at thresholds from `game_skill_ability`'s AUTOLEARN rows -//! (`acquire_method`). `skill.rs`'s hand-authored `skill_line` consts + `class_weapon_skill_lines` -//! now carry a deprecation pointer to these tables (see there) — the consts stay because the wire -//! protocol is still keyed on the same verbatim vanilla ids; they're just no longer the -//! AUTHORITATIVE source for "what skill lines exist". +//! Skill catalogues, race/class availability, and learn-level metadata. +//! The importer loads these rows from client data. Trainer and spell-learning operations read them. use spacetimedb::table; diff --git a/module/src/spell/cast/resolve.rs b/module/src/spell/cast/resolve.rs index 08627f002..0068a505f 100644 --- a/module/src/spell/cast/resolve.rs +++ b/module/src/spell/cast/resolve.rs @@ -52,7 +52,7 @@ pub(crate) fn resolve_cast_at( // its duplicate. Channel ticks / triggers / creature / debug / item-use casts pass false and the // relay DELIVERS the caster's visual (they never had a synchronous send). client_initiated: bool, - // The clicked GROUND point (118 phase 2), for a ground-targeted cast (CMSG_CAST_SPELL's + // The clicked GROUND point (118, for a ground-targeted cast (CMSG_CAST_SPELL's // DEST_LOCATION). `Some` → AREA effects splash from it and a ground patch anchors there; `None` for // every non-ground cast (self/unit-target/creature/trigger). Threaded verbatim to select_targets + // create_ground_area. @@ -88,7 +88,7 @@ pub(crate) fn resolve_cast_at_typed( // its duplicate. Channel ticks / triggers / creature / debug / item-use casts pass false and the // relay DELIVERS the caster's visual (they never had a synchronous send). client_initiated: bool, - // The clicked GROUND point (118 phase 2), for a ground-targeted cast (CMSG_CAST_SPELL's + // The clicked GROUND point (118, for a ground-targeted cast (CMSG_CAST_SPELL's // DEST_LOCATION). `Some` → AREA effects splash from it and a ground patch anchors there; `None` for // every non-ground cast (self/unit-target/creature/trigger). Threaded verbatim to select_targets + // create_ground_area. @@ -744,18 +744,6 @@ pub(crate) fn start_creature_spell( } } -/// The GATE SWEEP `resolve_cast_at` runs before spending anything — extracted (381) from what used to be -/// a single ~630-line function, where each of these checks landed as its own commented block, one per -/// work item, in this exact order. Every check here is READ-ONLY (no `.update`/`.insert`/`.delete`, no -/// `power`/item/reagent spend) — the hard boundary is the COST CHARGE in `resolve_cast_at` right after -/// this returns `Ok`, so "spend nothing before cost" holds by construction: nothing below can spend, and -/// everything that spends runs strictly after this call. `effects` is the SAME already-loaded, -/// already-sorted (E_INTERRUPT-first) vec `resolve_cast_at`'s effect loop reuses. -/// -/// One deliberate reorder from the pre-381 layout: `is_action_blocked` used to run BEFORE the caster row -/// was loaded (it only reads `game_aura`, not the row); `check_cast_gates` takes the row already in hand, -/// so the load now happens first in the caller. Both are independent early-bail reads with no shared -/// state, so this is a no-observable-difference reorder — not a behavior change. fn check_cast_gates( ctx: &ReducerContext, caster: &WorldEntity, @@ -1129,7 +1117,7 @@ fn check_cast_gate_suffix( if hdr.range_yd > 0 && target_guid != caster_guid { if let Some(target) = ctx.db.game_world_entity().guid().find(target_guid) { // World coordinates are per-map, so a Euclidean distance is only meaningful when both units - // share a map AND instance (work-item 190 slice 1) — a cross-map/cross-instance target is + // share a map AND instance, a cross-map/cross-instance target is // unreachable (treat as out of range) before trusting it. if caster.map_id != target.map_id || caster.instance_id != target.instance_id { return Err(CastRefusal::new( @@ -1385,9 +1373,9 @@ pub(crate) fn begin_cast( spell_id: u32, level: u8, target_guid: u64, - // 088: true only from the cast_spell reducer (the CMSG path) — see resolve_cast_at's param doc. + client_initiated: bool, - // The clicked GROUND point (118 phase 2) for a ground-targeted cast. An INSTANT/channel ground cast + // The clicked GROUND point (118 for a ground-targeted cast. An INSTANT/channel ground cast // passes it straight to resolve_cast_at; a TIMED one stashes it on the PendingCast row so the // completion (`fire_pending_cast`) can anchor the patch at the click. `None` for every normal cast. dest: Option<(f32, f32, f32)>, diff --git a/module/src/spell/cast/targeting.rs b/module/src/spell/cast/targeting.rs index a0a3a4b4b..33fe533f1 100644 --- a/module/src/spell/cast/targeting.rs +++ b/module/src/spell/cast/targeting.rs @@ -1,14 +1,3 @@ -//! Targeting + effect dispatch — `select_targets` / `aura_apply` / `apply_effect` and their per-kind -//! handlers (381 split: this used to be the first ~1700 lines of the old flat `cast.rs`; the cast CORE -//! — `resolve_cast_at` / `resolve_cast` / `begin_cast` — now lives in the sibling `resolve` module, -//! and the cast-bar teardown family — `pushback_cast` / `interrupt_cast` / `break_channel` / -//! `interrupt_cast_and_lock` — moved into `control.rs`, which already called back into it). -//! `cast::mod` glob re-exports both siblings, and `spell::mod` globs `cast::*` in turn, so every -//! `crate::spell::` call site is unchanged. `select_targets`/`apply_effect`/`aura_apply`/ -//! `recipe_reagents` are `pub(crate)` (not private) SOLELY because `resolve.rs` — a sibling module -//! under `cast/`, not a `use super::*` descendant — calls them; nothing outside `spell::` reaches -//! them via the flat re-export by accident (they aren't re-exported a second time by name anywhere). - use lyracore_shared::constants::tracer_spell; use spacetimedb::{log, ReducerContext, ScheduleAt, Table, TimeDuration, Timestamp}; @@ -64,7 +53,7 @@ impl EffectHit { } // =========================================================================================== -// Crafting professions — the end-to-end craft loop, DATA-DRIVEN (work-item 282). +// Crafting professions, the end-to-end craft loop, DATA-DRIVEN. // A craft recipe is a real `game_spell` with an `E_CREATE_ITEM` effect (product = `p0`) whose // mats live in `game_spell_reagent` (importer-filled from Spell.dbc's Reagent[1-8]) and whose // profession line + skill-up band live in `game_skill_ability` (SkillLineAbility.dbc). Two thin @@ -176,7 +165,7 @@ pub(crate) fn select_targets( caster_guid: u64, explicit: u64, e: &SpellEffect, - // The clicked GROUND point (118 phase 2). When `Some`, an AREA effect splashes from the dest + // The clicked GROUND point (118. When `Some`, an AREA effect splashes from the dest // (Flamestrike/Blizzard's initial impact centered on the click), NOT the caster/explicit unit. dest: Option<(f32, f32, f32)>, ) -> Vec { @@ -311,7 +300,7 @@ fn pick_aura_slot( /// just the A_SEAL-tagged one), and a same-NAME higher/lower rank displacing the other. Both call sites /// compute `spell_ids` with a DIFFERENT predicate (A_SEAL-sibling scan vs. same-name spell resolution) /// but converge on this identical shape (mirrored by `stacking.rs`'s group-eviction sweep, a third, -/// independent instance of the same pattern) — a future `spell_chain` import (work-item 102, the real +/// independent instance of the same pattern), a future `spell_chain` import, the real /// rank-chain data) swaps the RANK predicate that feeds this fn, never a second displacement mechanism. /// A no-op for an empty set (nothing to displace) — every existing call site is baseline-safe by /// construction, since an empty-set filter matches nothing either way. @@ -385,13 +374,7 @@ pub(crate) fn aura_apply( } else { hdr.duration_ms }; - // CC diminishing returns (work-item 192): PLAYER targets only, `A_CONTROL(mechanic)` effects only — - // `dr_category_for_effect` is `None` for every other kind/target (a creature, a non-CC aura), so this - // is a complete no-op for every existing aura. Runs on EVERY `aura_apply` call (fresh insert AND a - // same-spell refresh) — recasting the identical CC spell while the target is STILL under its previous - // application is exactly the work item's t=16 vector (the second poly hasn't been REMOVED yet, so this - // must scale the refresh too, not just a fresh insert). A `Refuse` bails out before anything else is - // touched (no aura placed/refreshed, no other state written) — matches "immune". + let now_micros = now.to_micros_since_unix_epoch(); let dr_category = crate::spell::stacking::dr_category_for_effect(ctx, e.kind, e.p0_kind, e.p0, target_guid); @@ -460,20 +443,6 @@ pub(crate) fn aura_apply( .collect(); displace_auras(ctx, target_guid, &other_seal_spell_ids); } - // Same-buff-RANK exclusivity: the importer keeps the shared base NAME across ranks - // (Arcane Intellect R1/R2, Frost Armor/Ice Armor, Demon Skin, ...) but each rank is a DISTINCT - // spell_id, so `pick_aura_slot`'s exact-spell_id dedupe doesn't catch a higher rank cast over a - // lower one — both auras stacked and double-folded their stat/armor effects. Generalizes the - // A_SEAL sibling-sweep above to ANY spell whose NAME matches this one's and whose spell_id differs: - // resolve the OTHER rank's spell_id(s) sharing this name, then hand them to the SAME - // `displace_auras` the seal sweep uses — one displacement mechanism, two predicates for "which - // spell_ids". Name-keyed is the honest slice available today; the real rank-chain data - // (`spell_chain`) is future work (work-item 102 tracks importing it) and will refine the PREDICATE - // later (a same-named-but-unrelated pair would incorrectly collide under this heuristic — - // acceptable for now, there is no such collision in the seed data) — the future `spell_chain` - // import swaps this predicate, not a third displacement mechanism. - // A re-cast of the SAME spell_id is excluded here (`a.spell_id != hdr.spell_id`) so a single-rank - // recast still only refreshes in place via the `existing` lookup below — never displaces itself. if !hdr.name.is_empty() { let spells = ctx.db.game_spell(); let other_rank_ids: std::collections::HashSet = auras @@ -550,7 +519,7 @@ pub(crate) fn aura_apply( a.proc_icd_ms = proc.icd_ms; auras.id().update(a); } else { - // Stacking-group conflict (work-item 192): resolve BEFORE picking a slot, so a Refuse never + // Stacking-group conflict : resolve BEFORE picking a slot, so a Refuse never // consumes a slot and an Apply's evictions free slots for this fresh insert. A same-spell recast // never reaches this branch (caught by the `existing`-by-effect_id match above) — matches "same- // spell-same-caster refresh keeps the existing precedence rule, runs BEFORE group logic". No-op @@ -580,13 +549,6 @@ pub(crate) fn aura_apply( tracer_spell::BUFF_SLOT_COUNT, tracer_spell::AURA_SLOTS, ) else { - // All slots full → DROP rather than colliding at slot 0 (which would corrupt the buff display). - // LOUD (work-item 192): this used to be a silent drop — now it ALSO relays SMSG_SPELL_FAILURE - // to the CASTER via the existing generic is_interrupted signal row (the same mechanism - // `interrupt_cast` uses; `gateway/src/stdb/subscriptions.rs`'s `on_cast` relay is untouched — - // it already fires for ANY `is_interrupted` row, keyed off `caster_guid == self_guid`, so this - // is a module-only change with zero gateway edits) — the 17th debuff / 33rd buff is refused - // with a clear cast-bar failure instead of vanishing silently. log::info!( "aura slot overflow: all {} slots on {target_guid} full — dropping spell {} (loud: relayed to caster {caster_guid})", tracer_spell::AURA_SLOTS, @@ -779,7 +741,7 @@ pub(crate) fn apply_effect( cast_target_guid: u64, level: u8, points: i32, - // The clicked GROUND point (118 phase 2) — only the E_PERSISTENT_AREA arm reads it (anchors the patch + // The clicked GROUND point (118, only the E_PERSISTENT_AREA arm reads it (anchors the patch // at the dest); every other effect ignores it. `None` for all non-ground casts. dest: Option<(f32, f32, f32)>, // This effect is running inside a **Triggered Cast** (a fired Proc). Every hit it deals is marked @@ -987,10 +949,6 @@ fn apply_damage_effect( // scale → CRIT → resist → absorb → health → threat. let is_crit = (ctx.random::() % 10_000) < spell_crit_bp(ctx, caster_guid); let crit_scaled = apply_spell_crit(scaled, is_crit); - // Magic resistance: the target's resistance to the spell's school reduces the (crit-scaled) - // damage, vanilla-style, capped 75%. No matching resist aura → unchanged (baseline-safe). - // `apply_target_damage` folds absorb shields in AFTER this, so threat accrues on the final - // post-ABSORB damage actually dealt, not on this pre-absorb resisted value. let after_resist = apply_resistance(ctx, target_guid, caster_guid, hdr.school_mask, crit_scaled); // resisted = what the school resist shaved off the crit-scaled hit (clamp ≥0 — a partial resist @@ -1383,7 +1341,7 @@ fn apply_inventory_effect( // life is rejected (the `pickpocketed` marker), so the same spawn can't be drained twice. Money // is the creature's TEMPLATE range (`money_min`/`money_max` — the SAME source the kill path // uses, reused via `roll_money`), falling back to the level heuristic for un-imported creatures - // (`money_max == 0`). NOT rank-scaled (vanilla pickpocket isn't). Item rows (work-item 210): + // (`money_max == 0`). NOT rank-scaled (vanilla pickpocket isn't). Item rows : // `roll_pickpocket_loot` rolls the creature's `game_pickpocket_loot` table into // `game_corpse_loot` keyed on the TARGET's (still-alive) guid, inside this SAME once-gate, so // items can never be drained twice per life either. Never on players (no pockets / not a @@ -1403,9 +1361,7 @@ fn apply_inventory_effect( rogue.money = rogue.money.saturating_add(copper); entities.guid().update(rogue); } - // Mark picked BEFORE rolling: roll_pickpocket_loot's refresh_lootable does its - // own fetch+write of this row (LOOTABLE flag), and writing our pre-roll snapshot - // AFTERWARDS clobbered that flag — the rolled items were unreachable (review catch). + let target_entry = target.entry; target.pickpocketed = true; // a re-pickpocket this life is rejected entities.guid().update(target); @@ -1570,12 +1526,6 @@ fn apply_character_effect( let new_stance = e.p0.clamp(0, u8::MAX as i32) as u8; if let Some(mut c) = ctx.db.game_world_entity().guid().find(caster_guid) { if c.stance != new_stance { - // Vanilla 1.12 clears RAGE on a stance switch (the "stance dance" cost; Tactical Mastery - // later retains some — not in this slice). Guarded to the RAGE power type, derived from - // class (DATA, not a hardcode) — so a future caster/energy stance isn't wrongly drained. - // Mana AND energy are spared by design; vanilla has no discrete "lose rage" effect, this - // wipe is engine behavior keyed on power type. Extracted to a pure predicate so the - // switch-vs-power-type gate is unit-testable without a live ReducerContext. let power_type = lyracore_shared::packing::power_type::for_class(c.class()); let clears_power = stance_switch_clears_power(c.stance, new_stance, power_type); c.stance = new_stance; @@ -1757,7 +1707,7 @@ fn create_ground_area( caster_guid: u64, anchor_guid: u64, amount: i32, - // The clicked GROUND point (118 phase 2). `Some` → the area anchors there (map/instance still come from + // The clicked GROUND point (118. `Some` → the area anchors there (map/instance still come from // the caster); `None` → it anchors on `anchor_guid`'s entity position (Consecration's T_SELF paladin). dest: Option<(f32, f32, f32)>, ) { @@ -2110,13 +2060,6 @@ mod effect_handler_tests { ); } - /// Debuff cap (work-item 192, verbatim vector): the REAL slot geometry gives exactly 16 debuff slots - /// (`AURA_SLOTS`(48) - `BUFF_SLOT_COUNT`(32)). 16 live debuffs fully occupy `[32, 48)`; a 17th distinct - /// debuff gets `None` (the existing 16 are untouched — `pick_aura_slot` takes `&self` and never - /// mutates its input), and `aura_apply`'s caller now also relays a LOUD `SMSG_SPELL_FAILURE` + module - /// log on that `None` (see the work-item report — the relay itself needs a live `ReducerContext` + - /// gateway, not unit-testable here). The buff cap (32) is the identical mechanism at the other - /// polarity, already covered by `debuffs_take_the_upper_range_...`'s `buffs_full` case above. #[test] fn sixteen_debuffs_full_refuses_the_seventeenth_leaving_the_sixteen_untouched() { let debuffs_full: Vec<(u8, u32)> = @@ -2173,9 +2116,7 @@ mod effect_handler_tests { super::pick_aura_slot(&debuffs_full, 8888, false, 32, 48), Some(0) ); - // Both ranges full simultaneously: a fresh debuff is refused (debuff range is what's full for - // it) and a fresh buff is refused (buff range is what's full for it) — each by its OWN range, - // not by the other polarity's occupancy. + let both_full: Vec<(u8, u32)> = buffs_full .iter() .chain(debuffs_full.iter()) diff --git a/module/src/spell/control.rs b/module/src/spell/control.rs index 3354cf308..f502f539f 100644 --- a/module/src/spell/control.rs +++ b/module/src/spell/control.rs @@ -201,7 +201,7 @@ pub fn is_rooted(ctx: &ReducerContext, unit_guid: u64) -> bool { } /// Is `unit_guid` FEARED — carrying an active `A_CONTROL(M_FEAR)` aura? A feared unit cannot ACT (the -/// ACTION gates fold it into `is_action_blocked`) and does not steer its OWN movement; instead the +/// TION gates fold it into `is_action_blocked`) and does not steer its OWN movement; instead the /// creature fear-flee pass force-walks it AWAY from the fear source each tick ("flees in terror"). It is /// the one mechanic that MOVES the unit: stun/poly freeze (no act, no move), root pins (act, no move), /// fear routs (no act, forced flee). Pure read over `game_aura` (no entity write); `false` for any unit @@ -358,10 +358,7 @@ pub(crate) fn break_auras_on_damage( .unwrap_or(false) }) .collect(); - // CC DR: break-on-damage is the THIRD removal event that starts the 15s DR window (with natural - // expiry — scheduler::tick_auras — and dispel — effects::dispel_target; 192 review finding #1). - // Without this stamp the window keeps the provisional apply-time value (scheduled expiry + 15s), - // running LONGER than vanilla's actual-break + 15s. No-op for non-CC auras / creature targets. + let now_micros = ctx.timestamp.to_micros_since_unix_epoch(); for a in &to_break { if let Some(category) = crate::spell::stacking::dr_category_for_effect( @@ -383,7 +380,7 @@ pub(crate) fn break_auras_on_damage( // break (landing a swing/cast reveals the ATTACKER) is a separate call at each call site; this is // the VICTIM-side half. No-op on a non-stealthed target (the common path). super::break_stealth(ctx, target_guid); - // Cast PUSHBACK-on-damage (work-item 039): DIRECT damage (melee swing, direct spell) PUSHES BACK the + // Cast PUSHBACK-on-damage : DIRECT damage (melee swing, direct spell) PUSHES BACK the // victim's in-progress TIMED cast (the cast bar slides `CAST_PUSHBACK_MS`, capped at // `CAST_PUSHBACK_MAX` pushbacks) — vanilla 1.12 does NOT cancel a regular cast on damage; only // Kick/Counterspell-style interrupts (`interrupt_cast_and_lock`) and CC (`interrupt_cast` via @@ -833,7 +830,7 @@ mod tests { } } - /// Cast pushback (work-item 039): the per-hit slide is cmangos's fixed 500ms, and the cap is 2 + /// Cast pushback : the per-hit slide is cmangos's fixed 500ms, and the cap is 2 /// pushbacks per cast (the vanilla cap) — so a 3rd+ direct hit is a documented no-op. #[test] fn pushback_constants_match_the_cmangos_convention() { diff --git a/module/src/spell/effects.rs b/module/src/spell/effects.rs index 3f288bd6b..2a62d82e6 100644 --- a/module/src/spell/effects.rs +++ b/module/src/spell/effects.rs @@ -20,7 +20,7 @@ use crate::game_world_entity; use crate::game_melee_attack; // The taxonomy consts, the `Aura` struct + the generated `game_aura`/`game_spell` accessor traits, and // every pure helper this file calls (`should_arm_spell_retaliation`, `dispel_category_matches`, -// `rederive_pool`, `absorb_draw`, `drain_shields`, `stat_bonus`, ...) are re-exported by `mod.rs`. +// `rederive_pool`, `absorb_draw`, `drain_shields`, `stat_bonus`,...) are re-exported by `mod.rs`. use super::*; /// Should a PLAYER's spell hit arm the target creature's reciprocal melee retaliation? True only when the @@ -57,13 +57,6 @@ pub(crate) fn arm_spell_retaliation(ctx: &ReducerContext, caster_guid: u64, targ } } -/// Apply `basis` spell damage to `target_guid`, credited to `caster_guid`. A CREATURE reduced to 0 -/// **dies** via the SHARED `combat::kill_creature` (corpse + loot + decay — identical to a melee blow); -/// a PLAYER is FLOORED at 1 hp (no spell-death of players yet). A non-lethal hit just subtracts. No-op -/// if the target is gone/dead. XP credited only when the caster is a PLAYER. [entity] -/// Returns `(dealt, absorbed)`: the post-mitigation damage written to health and the amount an A_ABSORB -/// shield soaked before the health write (so the cast-GO row can carry the absorbed figure for the -/// floating damage log). A target that's gone/dead absorbs nothing → `(0, 0)`. pub(crate) fn apply_target_damage( ctx: &ReducerContext, target_guid: u64, @@ -88,11 +81,7 @@ pub(crate) fn apply_target_damage( { return (0, 0); } - // Stage 1 of the SHARED damage pipeline: the caster's outgoing % (Defensive Stance's −10% - // plus any A_MOD_COMBAT(COMBAT_DMG_DONE) curse), the target's A_MOD_DAMAGE_TAKEN %, absorb shields - // (soaked BEFORE the kill check, so a fully-absorbed hit can't kill), and the GM godmode zero. - // This is the exact chain the melee/ranged swings fold — they used to be four hand-maintained - // copies of it. + let (dmg, absorbed) = crate::combat::fold_incoming_damage(ctx, caster_guid, target_guid, basis.max(0) as u32); let damage = crate::combat::final_damage(ctx, target_guid, dmg); @@ -125,7 +114,7 @@ pub(crate) fn apply_target_damage( pub(crate) fn dispel_target(ctx: &ReducerContext, target_guid: u64, category: u8) -> u32 { let auras = ctx.db.game_aura(); let spells = ctx.db.game_spell(); - // Collect full rows (not just ids): a DISPEL is the other DR-removal event (work-item 192) — CC + // Collect full rows (not just ids): a DISPEL is the other DR-removal event, CC // diminishing returns starts its 15s window at removal (natural expiry OR dispel), so each dispelled // row's `eff_*` fields must still be readable to check `dr_category_for_effect` BEFORE it's deleted. let debuffs: Vec = auras @@ -200,10 +189,7 @@ pub(crate) fn recompute_vitals(ctx: &ReducerContext, unit_guid: u64) { // --- max_health from effective stamina (every class has health) --- let login_max_health = crate::stats::max_health_for(ctx, race, class, e.level); - // Effective Stamina bonus = aura A_MOD_STAT(STA) PLUS the Stamina summed across EQUIPPED gear - // — so a +Stamina chest/ring actually grows the health bar, not just a Mark-of-the-Wild - // aura. Mirrors how combat's effective_strength folds aura + gear. No gear and no aura → 0, so the - // pool stays byte-identical to the bare level curve (baseline-safe). + let sta_bonus = stat_bonus(ctx, unit_guid, STAT_STA).saturating_add( crate::items::equipped_stat_bonus(ctx, unit_guid, crate::items::EquipStat::Stamina), ); diff --git a/module/src/spell/math.rs b/module/src/spell/math.rs index 18726fb30..4885d2f83 100644 --- a/module/src/spell/math.rs +++ b/module/src/spell/math.rs @@ -1,15 +1,3 @@ -//! Pure spell math + the aura-read folds, plus a couple of small self-contained aura-table maintenance -//! helpers (`remove_seal_auras` / `break_stealth` — a local collect-then-delete on `game_aura`, nothing -//! else touched). `mod.rs` re-exports these (`pub use math::*`) so every `crate::spell::` path -//! resolves. A handful of helpers used by the cast pipeline / scheduler are raised to `pub(crate)` -//! (`apply_resistance`, `resist_magic_damage`, `aura_moves_vitals`). The CROSS-CUTTING ORCHESTRATION half -//! that used to live here — `arm_spell_retaliation` / `apply_target_damage` / `dispel_target` / -//! `recompute_vitals` / `absorb_incoming`, every one of which either WRITES `game_world_entity` or -//! dispatches into another top-level module (`combat::apply_hit`'s kill fork, `stacking::dr_window_on_removal`) -//! — moved to `spell/effects.rs` (388): this file's own charter ("unit-tested off a live module", i.e. -//! never REQUIRING one) only became true again once those left. - -// `ReducerContext` is the live db handle every ctx-bound helper in this file threads. use spacetimedb::ReducerContext; // The `game_world_entity` accessor trait (generated by the table macro in world.rs) must be in scope to @@ -576,11 +564,6 @@ pub(crate) fn stance_switch_clears_power(old_stance: u8, new_stance: u8, power_t old_stance != new_stance && power_type == lyracore_shared::packing::power_type::RAGE } -/// Pure: does recasting the CURRENT stance/form leave it (back to caster form 0)? True only for the -/// druid form id range (>= STANCE_BEAR) — the 5875 client's shapeshift toggle-off IS a recast, and -/// forms are not auras so there is nothing else to cancel (156 review). A warrior recasting his -/// active stance stays a no-op: warriors are never formless in vanilla. Callers must write a ZERO -/// form byte on toggle-off, not `client_form_for_stance(0)` (that is warrior Battle, 17). [pure] pub(crate) fn form_recast_toggles_off(current: u8, recast: u8) -> bool { current == recast && recast >= STANCE_BEAR } diff --git a/module/src/spell/mod.rs b/module/src/spell/mod.rs index a3097632e..3adb08ffa 100644 --- a/module/src/spell/mod.rs +++ b/module/src/spell/mod.rs @@ -10,24 +10,7 @@ //! Runtime tables: `game_aura` (active auras, gateway-relayed as `UNIT_FIELD_AURA`), `game_spell_cooldown` //! (GCD), `game_pending_cast` (cast bar), `game_spell_cast_event` (the cast visual). The aura VALUES //! relay reuses the proven dirty_reset discipline (the gateway reads slot/spell_id/level/flags). [event] -//! -//! Submodules: -//! - `tables` — the spell/aura/cooldown/cast/schedule table structs (+ their generated accessors). -//! - `taxonomy` — the KIND/MECHANIC/param/target/combat-field/stat/resistance consts + tuning. -//! - `control` — the crowd-control model (the 7 CC predicates + immunity + break-on-damage), the -//! (381) cast-bar teardown family (`pushback_cast` / `interrupt_cast` / -//! `break_channel` / `interrupt_cast_and_lock`), and the death aura cleanup. -//! - `math` — pure helpers + aura-read folds only (388: no ctx-bound orchestration — see `effects`). -//! - `effects` — (388) the ctx-bound orchestration `math` used to carry: `arm_spell_retaliation` / -//! `apply_target_damage` / `dispel_target` / `recompute_vitals` / `absorb_incoming` — -//! every one of which WRITES to a live table, split out so `math`'s own charter is true. -//! - `cast` — a directory module (381 split): `targeting` (`select_targets` / `aura_apply` / -//! `apply_effect` + per-kind handlers) and `resolve` (the cast core — -//! `resolve_cast_at` + its extracted `check_cast_gates` gate sweep / `resolve_cast` / -//! `begin_cast` / the passive-apply path). -//! - `proc` — the Proc engine: the pure proc decision plus the ONE pass, from `apply_hit`. -//! - `scheduler` — the `#[reducer]`s (`tick_auras` / `fire_pending_cast` / `cast_spell`). -//! - `self_resurrect`: the Self-Resurrection Option, chosen at death and used from the death dialog. + //! //! Everything is re-exported below so every `crate::spell::` path resolves regardless of which //! submodule actually defines it. diff --git a/module/src/spell/proc.rs b/module/src/spell/proc.rs index cefef61bb..be3bb79aa 100644 --- a/module/src/spell/proc.rs +++ b/module/src/spell/proc.rs @@ -201,10 +201,6 @@ pub(crate) fn hit_bits(hit: &Hit) -> (u32, u32) { } } -/// The chance this Proc fires, in basis points, capped at certainty. Procs-per-minute replaces the -/// flat chance only for a Carrier that DEALT the hit: the rate is per swing, and a taken-side Proc has -/// no swing of its own to scale by (its attacker's weapon must not decide its rate). A flat chance at -/// or above 100 percent returns the whole roll space, so it always passes. pub(crate) fn proc_chance_bp(profile: &ProcProfile, side: ProcSide, attack_time_ms: u32) -> u32 { if profile.ppm > 0.0 && side == ProcSide::Dealer { let pct = profile.ppm * attack_time_ms as f32 / PPM_DIVISOR_PCT; @@ -213,12 +209,6 @@ pub(crate) fn proc_chance_bp(profile: &ProcProfile, side: ProcSide, attack_time_ (profile.chance as u32 * 100).min(BASIS_POINTS) } -/// Does the hit pass this Proc's school and family filter? -/// -/// A hit with NO spell is an auto-attack swing: it belongs to the physical school and to no family, so -/// a school filter judges it against physical (a fire-only filter excludes it; a physical filter keeps -/// it) and a family filter — which names spells — does not apply to it at all. That is cmangos's rule -/// in `IsSpellProcEventCanTriggeredBy`, and it is why a filter cannot silently disarm a weapon Proc. fn filter_matches(profile: &ProcProfile, spell: Option<&TriggeringSpell>) -> bool { let Some(spell) = spell else { return profile.school_mask == 0 || profile.school_mask & SCHOOL_MASK_PHYSICAL != 0; diff --git a/module/src/spell/scheduler.rs b/module/src/spell/scheduler.rs index 87005ecbe..fba9ce487 100644 --- a/module/src/spell/scheduler.rs +++ b/module/src/spell/scheduler.rs @@ -41,7 +41,7 @@ pub fn fire_pending_cast(ctx: &ReducerContext, sched: PendingCast) { // NOT client-initiated (088): the completion GO must reach the caster via the relay — the // synchronous send only covered the instant CMSG path, never a timed completion. false, - // The clicked ground point carried across the cast bar (118 phase 2): a timed ground-AoE + // The clicked ground point carried across the cast bar (118: a timed ground-AoE // (Flamestrike) anchors its patch here at COMPLETION. `has_dest` false → None (every normal cast). sched .has_dest @@ -267,13 +267,6 @@ pub(crate) fn do_cancel_aura( player_guid: u64, spell_id: u32, ) -> Result<(), String> { - // PASSIVE spells (talent passives) are server-enforced and NOT player-cancelable — refuse to strip - // them even if a modified client sends CMSG_CANCEL_AURA, so a player can't right-click off a talent - // bonus. NEGATIVE auras (debuffs, CC) are likewise refused: the vanilla client never offers - // cancel on a debuff, so only a modified client sends it — honoring it would let a player strip - // their own Polymorph/Fear and would ALSO dodge the DR-window stamp that only the real removal - // paths (expiry/dispel/break-on-damage) apply (192 review finding #2). A non-passive, - // non-negative (or unknown) spell falls through to the normal cancel. if let Some(spell) = ctx.db.game_spell().spell_id().find(spell_id) { if spell.attributes & SPELL_ATTR_PASSIVE != 0 { return Err(format!( @@ -365,7 +358,7 @@ pub fn tick_auras(ctx: &ReducerContext, _schedule: AuraSchedule) { // --- Periodic pass: collect the due ticks (snapshot owned aura rows), fold per-target health, then // flush the entity writes, then advance each due aura's next_tick — never write a table mid-iter. --- - // Perf catalog 1.9 — the same recipe work-item 232 used for the expiry pass below, on the SAME + // Perf catalog 1.9, the same recipe used for the expiry pass below, on the SAME // table: `by_next_tick` is a btree over the very column being compared, so `.filter(1..=now_micros)` // walks only rows that are actually DUE. The `0` sentinel (non-periodic: buffs, passives, stealth // presences — the overwhelming majority of rows) is excluded by starting the range at 1, so it needs @@ -643,20 +636,13 @@ pub fn tick_auras(ctx: &ReducerContext, _schedule: AuraSchedule) { // DoT), so "duration 0" does NOT mean permanent — only this kind does. Every other aura reaps on its // own timer. // - // Work-item 232: this used to be `auras.iter().filter(|a| a.expires_at <= now && ...)` — a full - // `game_aura` table scan every heartbeat (every 1s, per `AuraSchedule`'s interval), even though the - // overwhelming majority of rows aren't anywhere near expiry (long buffs, permanent presences, a busy - // raid's worth of other players' auras). `by_expiry` (tables.rs) is a btree index on the SAME - // `expires_at` column already being compared, so `.filter(..=now)` walks the index from its low end - // and stops at the horizon — it visits only rows AT OR PAST expiry, never the untouched tail. The - // A_STEALTH exclusion is kept as a cheap post-filter on that already-small candidate set (identical - // combined predicate, same result set, order of the two conditions doesn't matter for a filter). + let expired: Vec = auras .by_expiry() .filter(..=now) .filter(|a| is_due_for_expiry(a.eff_kind, a.expires_at, now)) .collect(); - // Evidence (work-item 232): the index scan's candidate count vs the table's total row count. + // Evidence : the index scan's candidate count vs the table's total row count. // `Table::count()` reads datastore metadata (O(1) — no scan), so logging it costs nothing extra; the // gap between the two numbers is the rows the OLD full-scan touched that this pass no longer does. // `log::debug!` (not `info!`) so a normal RUST_LOG=info run stays silent; enable `RUST_LOG=debug` (or @@ -688,7 +674,7 @@ pub fn tick_auras(ctx: &ReducerContext, _schedule: AuraSchedule) { remount.push(a.target_guid); } } - // CC diminishing returns (work-item 192): a NATURAL EXPIRY is one of the two REMOVAL events that starts + // CC diminishing returns : a NATURAL EXPIRY is one of the two REMOVAL events that starts // the 15s DR window (the other is a dispel — `effects::dispel_target`). `dr_category_for_effect` is a // no-op for a non-CC aura / a creature target, so this only touches the handful of player CC rows. // Runs BEFORE the delete loop below (order doesn't matter — `game_dr_state` is a separate table — but @@ -891,10 +877,6 @@ pub fn tick_ground_areas(ctx: &ReducerContext, _schedule: GroundAreaSchedule) { } } -/// Ensure the 500ms `tick_ground_areas` schedule row exists — called wherever a `game_ground_area` -/// row is BORN (perf catalog 1.20's disarm deletes it whenever the area table drains). Idempotent and -/// cheap: the table holds 0 or 1 rows. Must stay in lockstep with the interval in `seed::init` / -/// `debug_repair_after_publish` (formerly the standalone `debug_ensure_ground_area_schedule`). pub(crate) fn arm_ground_area_tick(ctx: &ReducerContext) { if ctx.db.game_ground_area_schedule().count() > 0 { return; diff --git a/module/src/spell/spellbook.rs b/module/src/spell/spellbook.rs index 451c02793..bc2477ba5 100644 --- a/module/src/spell/spellbook.rs +++ b/module/src/spell/spellbook.rs @@ -196,16 +196,6 @@ pub(crate) fn strip_spell_auras(ctx: &ReducerContext, guid: u64, spell_id: u32) moved_vitals } -/// A spell's rank-chain link within a spell family (e.g. Fireball Rank 1→2→3): `prev_spell` is the -/// immediately-lower rank (0 for the family's first rank), `first_spell` the family's rank-1 id, `rank` -/// this row's own ordinal, `req_spell` an additional required spell (unused this slice — read/stored for -/// completeness, not yet consulted by any gate). Read by `trainer::apply_trainer_buy`'s rank-prereq gate -/// (`by_first` is not used there today — kept for a future "list every rank in a family" query, the same -/// speculative-index posture `game_trainer_spell::by_trainer` set). Sourced from cmangos `spell_chain` -/// (work-item 102, reduced scope) — PUBLIC since 258 (rank supersession): the gateway subscribes it to -/// (a) send SMSG_SUPERCEDED_SPELL instead of LEARNED_SPELL when a trainer buy upgrades a known rank and -/// (b) collapse superseded ranks out of the login SMSG_INITIAL_SPELLS. No Timestamp → plain -/// `spacetime sql` clear+reload, exactly like `game_createinfo_spell`. [reference] #[table(accessor = game_spell_chain, public, index(accessor = by_first, btree(columns = [first_spell])))] pub struct SpellChain { #[primary_key] @@ -217,7 +207,7 @@ pub struct SpellChain { } /// One "learning `parent_spell` also auto-teaches `learn_spell`" dependent (cmangos `spell_learn_spell`, -/// work-item 102 reduced scope) — e.g. a weapon-skill spell that auto-grants a companion passive. Read +/// reduced scope), e.g. a weapon-skill spell that auto-grants a companion passive. Read /// by [`learn_spell_with_dependents`] via `by_parent`. Module-only, no Timestamp, unsubscribed — same /// shape/rationale as `SpellChain` above. [reference] #[table(accessor = game_spell_learn, index(accessor = by_parent, btree(columns = [parent_spell])))] @@ -229,17 +219,6 @@ pub struct SpellLearn { pub learn_spell: u32, } -/// Learn `spell_id`, then auto-teach each ONE-LEVEL dependent listed in `game_spell_learn.by_parent` -/// (cmangos `spell_learn_spell`, work-item 102 reduced scope). ONE level only, deliberately not -/// recursive: cmangos classic data never nests an auto-learn chain more than one hop deep, so a second -/// lookup pass would only ever re-scan empty results — not worth paying for a deeper walk (or its -/// call-stack/cycle exposure) for data that doesn't need it. `learn_spell`'s own check-before-insert -/// already makes any individual grant idempotent (so even a malformed cyclic row could never double-teach -/// or loop within this one level), but capping the walk at one hop means we never even attempt a second -/// one. TRAINER-PATH ONLY — call this from `trainer::apply_trainer_buy`'s spell-grant arm, NEVER from -/// plain [`learn_spell`] (character creation / talents / quest rewards): firing dependents universally -/// would change the createinfo grant's spell COUNT and break work-item 212's provenance count-parity -/// runbook, which assumes createinfo grants exactly its listed rows. [entity] pub(crate) fn learn_spell_with_dependents( ctx: &ReducerContext, guid: u64, @@ -259,11 +238,6 @@ pub(crate) fn learn_spell_with_dependents( } } -/// Projects `game_spell_learn`-shaped `(parent_spell, learn_spell)` pairs down to the dependent spell ids -/// taught alongside `parent_spell` — the pure part of `learn_spell_with_dependents`'s DB read, extracted -/// so the "ONE level of dependents" selection is unit-tested without a `ReducerContext` (the module crate -/// has no ctx harness by design). Named honestly: the ctx-bound `by_parent().filter(...)` read itself is -/// NOT covered by a test here — only this projection over already-fetched rows is. pub(crate) fn dependent_spell_ids(rows: &[(u32, u32)], parent_spell: u32) -> Vec { rows.iter() .filter(|&&(parent, _)| parent == parent_spell) @@ -276,7 +250,7 @@ mod tests { use super::{createinfo_row_matches, dependent_spell_ids}; use crate::seed::CREATEINFO_KIT; - // --- work-item 102 (reduced scope): spell-chain / spell-learn dependents ------------------------ + // --- (reduced scope): spell-chain / spell-learn dependents ------------------------ /// `dependent_spell_ids` returns ONLY the rows whose parent matches, in row order, and is empty for /// a parent with no dependents at all (the "no game_spell_learn row" fallback: a plain rank-only diff --git a/module/src/spell/stacking.rs b/module/src/spell/stacking.rs index d2e5b5c49..1d5e9bb4f 100644 --- a/module/src/spell/stacking.rs +++ b/module/src/spell/stacking.rs @@ -1,23 +1,3 @@ -//! Stacking-group exclusion + CC diminishing returns — work-item 192. `module/src/spell/cast/targeting.rs`'s -//! `aura_apply` (already the sole `game_aura` insert site — verified by grep, see the work-item report) is -//! the ONLY caller of `apply_group_conflict`/`resolve_dr_for_target`, so every aura placement in the engine -//! routes through this file (the "filtered_gossip_options lesson": one chokepoint, never a second copy of -//! the policy). -//! -//! Two concerns, two pure decision fns, sharing one result type (`ApplyDecision`) — they're mutually -//! exclusive per effect (a spell effect is either a stacking-group member OR a CC-DR effect, never both in -//! today's data), so there's no shared "incoming application" struct pretending to unify them: -//! 1. STACKING GROUPS (`game_spell_group` + `game_spell_group_rule`) — same-effect exclusive families -//! across casters/spells (Fortitude, Blessings, armor debuffs, ...). Module-only tables (no -//! `public`, no gateway binding — mirrors the `game_spell_chain`/`game_spell_learn` precedent), -//! because `game_aura` itself carries everything the client needs (the eviction just deletes rows); -//! NO `game_aura` column was added for this (verified: see the report's "zero subscribed-schema -//! change" note). Decided by [`resolve_group_conflict`]. -//! 2. CC DIMINISHING RETURNS (`game_dr_state`) — PLAYER targets only; same-category CC within 15s of -//! the PREVIOUS REMOVAL lands at 100/50/25/0% duration. The window starts at REMOVAL (natural expiry -//! OR dispel), never at apply — the classically-misimplemented part the work item calls out. Decided -//! by [`resolve_dr`]. - use spacetimedb::{log, table, ReducerContext, Table}; use crate::game_world_entity; @@ -62,10 +42,6 @@ pub struct SpellGroupRule { pub rank_is_comparable: bool, } -/// Rule byte constants (`game_spell_group_rule.rule`) — mirrors the work item's architecture section -/// verbatim. `seed.rs` and `group_rule_from_u8` are the only readers. `RULE_STACKS` (0) is never matched -/// BY NAME in `group_rule_from_u8` (it's the `_` wildcard fallback, so any future rule byte the importer -/// doesn't recognize degrades to it too) — `#[allow(dead_code)]` documents that as deliberate, not a bug. #[allow(dead_code)] pub(crate) const RULE_STACKS: u8 = 0; pub(crate) const RULE_EXCLUSIVE: u8 = 1; @@ -164,7 +140,7 @@ pub(crate) enum ApplyDecision { }, } -/// THE stacking-group chokepoint (work-item 192). Called by `apply_group_conflict` for a fresh +/// THE stacking-group chokepoint. Called by `apply_group_conflict` for a fresh /// stacking-group member application (a same-spell refresh never reaches this — see that fn's doc). Pure: /// no DB access, no logging, no side effects — the caller does all the I/O and acts on the returned /// `ApplyDecision`. @@ -316,7 +292,7 @@ pub(crate) fn buff_group_status( } } -/// THE CC-diminishing-returns chokepoint (work-item 192). Called by `resolve_dr_for_target` for every +/// THE CC-diminishing-returns chokepoint. Called by `resolve_dr_for_target` for every /// `A_CONTROL(mechanic)` effect on a player target. `prior` is the DR window state read for this /// `(target, category)` BEFORE this application (`None` if the target has never taken this category of /// CC, or its prior window has lapsed by `now_micros`). Pure: no DB access, no logging, no side effects — @@ -394,12 +370,6 @@ pub(crate) fn rank_of(ctx: &ReducerContext, spell_id: u32) -> u8 { .unwrap_or(0) } -/// Does `(eff_kind, eff_p0_kind, eff_p0)` name a DR-tracked CC mechanic, AND is `target_guid` a PLAYER? -/// Creatures are explicitly OUT (per the work item: "creatures take full duration, skip the PvE stun cap" -/// — this fn returning `None` for a creature is exactly that skip). The ONE mapping both the apply-side -/// (`cast::aura_apply`, before the row exists — passes the raw effect fields) and the two removal-side -/// hooks (`scheduler::tick_auras`'s expiry pass, `effects::dispel_target` — pass an existing `Aura` row's -/// frozen `eff_*` fields) go through, so "is this aura DR-tracked" has exactly one definition. pub(crate) fn dr_category_for_effect( ctx: &ReducerContext, eff_kind: u8, @@ -578,11 +548,6 @@ pub(crate) fn bump_dr_level( } } -/// Stamp the AUTHORITATIVE DR window on `(target_guid, category)`'s removal — `removed_at_micros + 15s`, -/// per the work item's "window starts at removal" pin. Called from BOTH removal paths: -/// `scheduler::tick_auras`'s natural-expiry pass and `effects::dispel_target`'s dispel. No-op if no row exists -/// (can't happen in practice — a DR-tracked aura always got `bump_dr_level`'d at its own apply — but -/// defensive rather than materializing a level-0 row purely from a removal). pub(crate) fn dr_window_on_removal( ctx: &ReducerContext, target_guid: u64, @@ -624,11 +589,6 @@ pub(crate) fn sweep_dr_state(ctx: &ReducerContext) { mod tests { use super::*; - // --- Stacking groups ----------------------------------------------------------------------- - - /// Fortitude rank cross-caster (verbatim work-item vector): rank2(caster A) onto a target already - /// carrying rank1(caster B) — EXCLUSIVE_STRONGER → Apply, evicting B's aura. The reverse (rank1 onto an - /// existing rank2) is weaker → Refuse. #[test] fn fortitude_rank_beats_lower_rank_any_caster_exclusive_stronger() { let rank1_b = AuraSummary { @@ -845,10 +805,10 @@ mod tests { // --- CC diminishing returns ------------------------------------------------------------------ - /// The verbatim poly DR timeline (t in seconds -> micros): t=0 full (fresh); t=12 (aura #1 expired at - /// t=10, its removal set the window to expire at t=25) -> 50%, level->2; t=16 (aura #2, still active, + /// The verbatim poly DR timeline (t in seconds -> micros): t=0 full (fresh); t=12 (aura expired at + /// t=10, its removal set the window to expire at t=25) -> 50%, level->2; t=16 (aura, still active, /// hasn't been removed yet — its own apply-time level bump already put the row at level 2) -> 25%, - /// level->3; t=20 (aura #3 removed at t=18.5, its removal reset the window to expire at t=33.5, still + /// level->3; t=20 (aura removed at t=18.5, its removal reset the window to expire at t=33.5, still /// live) -> Refuse{immune}; t=40.1 (> t=33.5, window lapsed) -> full duration, level reset. #[test] // Every timestamp in this vector is written ` * SECOND` so the timeline reads off the page in @@ -870,7 +830,7 @@ mod tests { // bump_dr_level's own math (mirrored here, no ctx): pre_level 0 -> new_level 1. assert_eq!(dr_pre_level(None, t0), 0); - // Aura #1 (full 10s) removed at t=10 (natural expiry) -> window row: level 1, expires t=10+15=25. + // Aura (full 10s) removed at t=10 (natural expiry) -> window row: level 1, expires t=10+15=25. let row_after_removal_1 = DrWindow { level: 1, window_expires_micros: 25 * SECOND, @@ -894,7 +854,7 @@ mod tests { window_expires_micros: 17 * SECOND + DR_WINDOW_MICROS, }; - // t=16: aura #2 still active (expires t=17) — a recast reads the row AS-IS (no removal happened), + // t=16: aura still active (expires t=17), a recast reads the row AS-IS (no removal happened), // level 2 -> 25%, i.e. 2.5s of a 10s base. let t16 = 16 * SECOND; let decision16 = resolve_dr(Some(row_after_apply_2), t16); diff --git a/module/src/spell/tables.rs b/module/src/spell/tables.rs index 022b484da..be64a8a42 100644 --- a/module/src/spell/tables.rs +++ b/module/src/spell/tables.rs @@ -10,14 +10,6 @@ use spacetimedb::{table, Identity, ReducerContext, ScheduleAt, Table, Timestamp} use super::{fire_pending_cast, fire_spell_impact, tick_auras, tick_ground_areas}; use crate::WorldEntity; -// =========================================================================================== -// Spell definition tables [static] — hand-authored OR importer-filled from Spell.dbc. Public -// (reference data the client/gateway read). No Timestamp → SQL/seed-loadable. -// =========================================================================================== - -/// A castable spell's **header** — the whole-spell scalars (mapped ~1:1 from `SpellEntry`). The -/// per-effect behavior lives in `game_spell_effect`; this row carries only what's shared by every -/// effect of the spell (duration, school, dispel category, GCD/cost/cast-time, stacking bound). [static] #[table(accessor = game_spell, public)] pub struct Spell { #[primary_key] @@ -120,7 +112,7 @@ pub struct SpellEffect { pub enters_combat: bool, } -/// One REAGENT a spell consumes (work-item 282: the real multi-reagent recipe model that replaced +/// One REAGENT a spell consumes: the real multi-reagent recipe model that replaced /// the hardcoded `cast::RECIPES` map). Filled by the importer from `Spell.dbc` `Reagent[1-8]` / /// `ReagentCount[1-8]` during the wholesale spell import — every real recipe (and any reagent- /// consuming spell) carries its true mats here, keyed by the REAL vanilla spell id, so the craft @@ -179,7 +171,7 @@ pub struct SpellProcEvent { /// columns are the **frozen typed snapshot** (computed once at apply) so periodic ticks + combat /// stat-reads are self-contained — NO template/effect re-join on the hot path. [event] /// -/// `by_expiry` (work-item 232) is a plain INDEX ADD over the already-existing `expires_at` column — no +/// `by_expiry` is a plain INDEX ADD over the already-existing `expires_at` column, no /// new column, no default, no data migration; a schema/metadata-only change on this gateway-SUBSCRIBED /// table (verified by `gateway/tests/schema_parity.rs`, which checks columns/bindings, not indexes). /// `scheduler::tick_auras`'s expiry pass range-scans `by_expiry().filter(..=now)` instead of `.iter()`ing @@ -275,21 +267,11 @@ pub struct Aura { /// Frozen internal-cooldown length. 0 = no cooldown (fire as often as the roll allows). #[default(0u32)] pub proc_icd_ms: u32, - /// Micros-since-epoch this Proc is ready again. 0 = ready now; stamped `now + proc_icd_ms` on a fire. #[default(0i64)] pub proc_ready_micros: i64, } -// UNIT-keyed character-owned sweep (the warm-handoff hot-state audit). `Aura`'s columns -// name ANY unit (`target_guid`/`caster_guid` — creatures have auras too), never `character_guid`/ -// `player_guid`/`owner_guid`, so `tripwires.rs`'s `character_owned_tripwire` never flags this table and no -// marker was mandatory. That silence is exactly why the TRANSFER half went missing while the DELETE -// half got hand-rolled straight into `world::cascade_delete_character` (see that fn's own comment, -// "auras ON the character... the character_owned tripwire exempts the tables"): a warm handoff carried -// every manifest table but dropped a mid-fight buff, a DoT, a HoT, or a Rogue's Stealth on the floor — -// silently, because nothing here ever claimed otherwise. Scoped to `target_guid == character_guid`, -// the exact predicate `cascade_delete_character`'s block already uses; a mob's own aura, or a debuff -// the character CAST on someone else, is untouched either way (`caster_guid` never gates this sweep). +// Transfer and deletion own auras on the Character. Auras cast on other units remain untouched. crate::character_owned!(delete, fn sweep_delete_game_aura(ctx, character_guid) { let auras = ctx.db.game_aura(); let ids: Vec = auras.by_target().filter(&character_guid).map(|a| a.id).collect(); @@ -400,7 +382,7 @@ pub struct SpellCastEvent { // cast each spell once). END-appended + #[default(0u32)] → additive auto-migration. #[default(0u32)] pub cooldown_ms: u32, - // PUSHBACK payload (work-item 039): the number of milliseconds added to the in-progress timed + // PUSHBACK payload : the number of milliseconds added to the in-progress timed // cast. The Gateway relays SMSG_SPELL_DELAYED{guid, delay_time}. Kind names the signal; this // value remains zero on every other kind and preserves decoding for old rows. // END-appended + #[default(0u32)] → additive auto-migration (the publish-migration rule). @@ -408,7 +390,7 @@ pub struct SpellCastEvent { pub delay_ms: u32, // EFFECTIVE health restored to the primary target by this cast (overheal excluded; summed // across E_HEAL effects). >0 on the cast-GO row → the gateway relays SMSG_SPELLHEALLOG (the - // green floating number + combat-log line, work-item 251). Binding hand-synced (see the + // green floating number + combat-log line. Binding hand-synced (see the // delay_ms note above). END-appended + #[default(0u32)] → additive auto-migration. #[default(0u32)] pub healed: u32, @@ -419,7 +401,7 @@ pub struct SpellCastEvent { #[default(false)] pub is_proc_log: bool, // The melee swing outcome an on-next-swing FIRE row rode (114): CombatEvent hit_info codes - // (0 normal, 1 crit, 2 miss, 3 dodge, 4 parry, ...). The relay shapes the SMSG_SPELL_GO miss + // (0 normal, 1 crit, 2 miss, 3 dodge, 4 parry,...). The relay shapes the SMSG_SPELL_GO miss // list from it when damage == 0 — the client then prints the yellow "Your Heroic Strike // missed/was dodged/was parried" line instead of a white MISS. 0 (normal) on every other row. // Binding hand-synced. END-appended + #[default(0u8)] -> additive auto-migration. @@ -461,18 +443,6 @@ pub struct SpellCastEvent { } impl SpellCastEvent { - /// A baseline `SpellCastEvent` row for `caster_guid`/`spell_id`: `id`=0, `created_at`=`ctx.timestamp`, - /// and the AOI address (`map_id`/`instance_id`/`grid_x`/`grid_y`) stamped from ONE - /// [`crate::helpers::grid_of`] lookup — every other field at its neutral zero/false. Replaces the - /// ~20-field literal + 4× `grid_of` copy-paste this used to require at every call site (perf catalog - /// audit, 2026-08-06): a call site now overrides only the 2-4 fields that carry real signal, via - /// struct-update syntax, e.g. - /// `SpellCastEvent { is_interrupted: true, ..SpellCastEvent::signal(ctx, caster_guid, spell_id, - /// SpellCastEventKind::Interrupt) }`. - /// - /// Use [`Self::signal_at`] instead when the caster's live [`WorldEntity`] is already in hand — it - /// skips this lookup entirely (a landed swing carrying a seal proc + a queued strike used to pay the - /// `game_world_entity` PK lookup up to twelve times over for what is, in every case, the SAME row). pub(crate) fn signal( ctx: &ReducerContext, caster_guid: u64, @@ -559,7 +529,7 @@ pub struct SpellCooldown { /// can be off-GCD yet still on its own cooldown). A `cooldown_ms == 0` spell writes NO row here, so every /// current seed spell is unaffected (baseline-safe). Logical key is `(caster_guid, spell_id)`; mirrors the /// `game_aura` idiom — an `#[auto_inc]` PK plus a `by_caster` btree index, the specific spell located with -/// a `.find(|r| r.spell_id == ..)` on the caster's rows (no lossy u64+u32 PK pack). A NEW table → the GCD +/// a `.find(|r| r.spell_id ==..)` on the caster's rows (no lossy u64+u32 PK pack). A NEW table → the GCD /// binding is untouched; this table's binding must be generated for the gateway to relay SMSG_SPELL_COOLDOWN. #[table(accessor = game_spell_cd, public, index(accessor = by_caster, btree(columns = [caster_guid])))] pub struct SpellCd { @@ -575,7 +545,7 @@ pub struct SpellCd { /// victim's whole SCHOOL of magic is locked for ~5s: a cast whose `game_spell.school_mask` matches a LIVE /// lockout row for that caster is rejected by `resolve_cast_at` (until `until > now`). Logical key is /// `(caster_guid, school)`; mirrors the `game_spell_cd` idiom — an `#[auto_inc]` PK plus a `by_caster` -/// btree, the specific school located with `.find(|r| r.school == ..)` on the caster's rows. A NEW public +/// btree, the specific school located with `.find(|r| r.school ==..)` on the caster's rows. A NEW public /// table → no existing binding changes, and a plain `publish` auto-migrates it (additive, no rows). Public /// so the gateway can later relay an SMSG_SPELL_COOLDOWN-style lock (the server-authoritative gate works /// without it). Rows are never GC'd (like `game_spell_cd`): the `until > now` predicate makes expired rows @@ -683,7 +653,7 @@ pub struct PendingCast { // auto-migration (the publish-migration rule). #[default(0)] pub pushback_count: u8, - // GROUND-TARGET dest (118 phase 2): the clicked ground point for a ground-AoE cast (Flamestrike patch, + // GROUND-TARGET dest (118: the clicked ground point for a ground-AoE cast (Flamestrike patch, // Blizzard, Rain of Fire) so a TIMED ground cast carries its dest from begin_cast to the completion // (`fire_pending_cast` → `resolve_cast_at`). `has_dest` distinguishes "no dest" from a legitimate // (0,0,0) point. Same END-appended `#[default]` additive auto-migration as `pushback_count`; still no diff --git a/module/src/spell/taxonomy.rs b/module/src/spell/taxonomy.rs index 121871cdf..72387751c 100644 --- a/module/src/spell/taxonomy.rs +++ b/module/src/spell/taxonomy.rs @@ -312,7 +312,7 @@ pub(crate) const SPEED_MOUNTED: u8 = 3; // --- STANCES / FORMS (the value WorldEntity.stance holds + the value `p0` carries on an E_SET_STANCE // effect; 0-based, our OWN small taxonomy — THE DEFINITION SITE of the stance-id convention). 0 = Battle // (the login/default stance — every non-stance class also sits at 0, so they're "in Battle" inertly but -// never gate on it). Work-item 156 widened the space past the Warrior trio to the Druid COMBAT forms +// never gate on it). widened the space past the Warrior trio to the Druid COMBAT forms // (Bear/Cat/Dire Bear — the shapeshift switches the importer name-rescues to E_SET_STANCE); the druid // non-combat forms (Aquatic/Travel/Moonkin/Tree) remain out of scope, their markers stay inert A_FLAGs. // The ONE convention, end to end: @@ -379,17 +379,6 @@ pub(crate) const STAT_ALL: u8 = 0xFF; // an A_MOD_STAT effect that buffs every a // --- resistance schools (the value of `p0` for an A_MOD_RESISTANCE aura; a bitmask, armor = bit 0) --- pub(crate) const RESIST_ARMOR: u8 = 0x01; // physical armor (bit 0 of the school mask) -/// Canonical, ordered list of every INSTANT (`E_*`) effect kind — the deduplicated effect taxonomy the -/// importer maps mangos `Effect` ids onto. This is the SINGLE source of truth for "every instant kind -/// that exists": `tests.rs`'s `instant_kind_wire_values_exhaustive` loops it (never a hand-copied -/// duplicate) so a kind is exhaustively covered by construction, and referencing every `E_*` const here -/// keeps an as-yet-unwired one from tripping `dead_code` (CI's clippy gate runs `-D warnings`, so a -/// kind added to the taxonomy but left OFF this slice fails the build, not just a test). This -/// replaces the old `_TAXONOMY` scaffold + four separately hand-copied `E_*`/`A_*` lists in `tests.rs`, -/// one of which had drifted (E_BLINK/E_PERSISTENT_AREA/E_FISH/E_OPEN_LOCK were missing from it). -/// `#[allow(dead_code)]`: like `_TAXONOMY` before it, this binding is read only from `#[cfg(test)]` -/// code (`tests.rs`), so the non-test `lib` build never itself "reads" `ALL_INSTANT_KINDS` — but every -/// `E_*` const named INSIDE it is still counted as used, which is the array's actual job. #[allow(dead_code)] pub(crate) const ALL_INSTANT_KINDS: &[u8] = &[ E_DAMAGE, diff --git a/module/src/spell/tests.rs b/module/src/spell/tests.rs index 10026b543..05672c72f 100644 --- a/module/src/spell/tests.rs +++ b/module/src/spell/tests.rs @@ -39,9 +39,6 @@ fn spell_crit_is_one_and_a_half() { assert_eq!(apply_spell_crit(0, true), 0); // a 0-damage effect stays 0 } -/// Incoming-damage modifier (A_MOD_DAMAGE_TAKEN): signed percent, 0 = no-op, ≥100% reduction = immunity. -/// `apply_damage_pct` is direction-neutral (also folds the OUTGOING percent — see its doc comment); this -/// test exercises it through the incoming-damage framing its name used to (wrongly) assert alone. #[test] fn damage_taken_modifier_scales_and_clamps() { assert_eq!(apply_damage_pct(100, 0), 100); // no aura → unchanged (baseline-safe) @@ -75,7 +72,7 @@ fn stance_allows_gate() { assert!(stance_allows(0x05, STANCE_BERSERKER)); // An out-of-range stance is disallowed under a non-zero mask (the shift is bounded → no overflow/panic). assert!(!stance_allows(0x01, 9)); - // Druid combat forms (work-item 156 — stance ids 3/4/5 flow through the SAME gate/mask convention): + // Druid combat forms (stance ids 3/4/5 flow through the SAME gate/mask convention): // a Maul/Growl-shaped Bear|DireBear mask (bits 3,5 = 0x28) admits Bear and Dire Bear only. assert!(stance_allows(0x28, STANCE_BEAR)); assert!(stance_allows(0x28, STANCE_DIRE_BEAR)); @@ -144,9 +141,6 @@ fn stance_switch_clears_power_only_on_real_switch_for_rage() { )); } -/// Form-recast toggle-off gate (156 review): recasting the ACTIVE druid form (Bear/Cat/DireBear) -/// leaves it; a warrior recasting his active stance stays a no-op (warriors are never formless); -/// switching TO a different form/stance is never a toggle (the change arm handles it). #[test] fn form_recast_toggles_off_only_for_active_druid_forms() { // Druid recasting the form he is IN → leave it. @@ -512,7 +506,7 @@ fn instant_kind_wire_values_exhaustive() { ); assert!( !passive_applies_effect_kind(*k), - "instant kind 0x{k:02x} must not reach the passive-apply path (#90: Consecration's \ + "instant kind 0x{k:02x} must not reach the passive-apply path (Consecration's \ E_PERSISTENT_AREA-only shape must never mint a spurious login/world-change buff)" ); for other in &ALL_INSTANT_KINDS[i + 1..] { @@ -547,7 +541,7 @@ fn aura_kind_wire_values_exhaustive() { ); assert!( passive_applies_effect_kind(*k), - "aura kind 0x{k:02x} must still reach the passive-apply path (#90 control)" + "aura kind 0x{k:02x} must still reach the passive-apply path" ); for other in &ALL_AURA_KINDS[i + 1..] { assert_ne!( @@ -718,7 +712,7 @@ fn break_on_damage_flag_decode() { assert!(!breaks_on_damage(0x2)); // a different aura_interrupt bit (not BREAK_ON_DAMAGE) → not broken } -// --- Aura-expiry reap gate (work-item 232) -------------------------------------------------------- +// --- Aura-expiry reap gate -------------------------------------------------------- // `tick_auras`'s expiry pass now range-scans `game_aura.by_expiry()` (a btree index on `expires_at`) to // the horizon instead of `.iter()`ing the whole table, then applies `is_due_for_expiry` as the exact same // combined predicate the old full scan used inline (`a.expires_at <= now && a.eff_kind != A_STEALTH`). diff --git a/module/src/stats.rs b/module/src/stats.rs index 78ee8e358..8e7acda08 100644 --- a/module/src/stats.rs +++ b/module/src/stats.rs @@ -175,12 +175,6 @@ pub(crate) struct LevelStatsDelta { pub old_spirit: u32, } -/// Write the level-derived stat block onto `e` for `(race, class, level)`: the five base attributes, -/// armor (`agility * 2`, classic base armor), and max health/power — from the SAME -/// `base_attributes_for`/`max_health_for`/`max_power_for` curve lookups every call site used to inline -/// separately. Does **not** touch the CURRENT `health`/`power` pool (a ding always fully heals; a -/// level-set refills fully; login instead resumes from the persisted value) or anything outside the -/// stat block (display, faction, position, …) — those stay each caller's own concern. /// /// This was three hand-mirrored copies (`build_player_entity` at login, `grant_xp`'s ding loop, and /// `set_character_level`) held in sync only by a comment ("mirrors build_player_entity"). The third @@ -216,15 +210,6 @@ pub(crate) fn apply_level_stats( old } -/// Set `character_guid`'s level and recompute `max_health`/`max_power`/the five base attributes/armor -/// from the real stat curve, via `apply_level_stats` (the SAME writer `player_login` and the ding loop -/// use — not reimplemented). Health/power are refilled to the new max. Persists the level to the -/// durable `game_character` row too, so a relog keeps it. Shared by `debug::debug_set_level` -/// (the test-harness lever) and `gm::gm_command`'s `.level` (work-item 223's playtest kit) so the two -/// paths can never drift. Works for an OFFLINE character too (266): the old live-entity requirement -/// silently no-opped every fixture that set a logged-out character's level (exploration set Ginger to 5 -/// while she was offline — the wire phase then ran at her real, drifted level), and login rebuilds the -/// entity from the character row anyway. Errors only when NEITHER a live entity nor a character row exists. pub(crate) fn set_character_level( ctx: &ReducerContext, character_guid: u64, diff --git a/module/src/talent.rs b/module/src/talent.rs index 7c1dfcdb3..d976a3bfb 100644 --- a/module/src/talent.rs +++ b/module/src/talent.rs @@ -1,33 +1,5 @@ -//! Talents — FIRST SLICE: the talent engine + a starter set of PASSIVE Warrior talents. -//! -//! A talent is a tree node (`game_talent`) a character spends points into (`game_character_talent`), -//! whose effect is a PASSIVE aura applied at login / on-learn. The effect rides the existing spell/aura -//! pipeline: each talent maps to ONE `game_spell` carrying an `A_MOD_*` effect, applied with -//! `points = rank * base_points` — so rank 3 of Cruelty (+100 crit/rank) applies +300 crit. Re-applying -//! refreshes the SAME aura (keyed by `effect_id`), so a rank-up updates the magnitude with no stacking and -//! a relog is idempotent. This reuses the proven combat folds (`effective_crit_bp`/`effective_armor`/…) — -//! a learned talent is a real, server-verifiable combat change (`debug_compute_swing`). -//! -//! Talent points are DERIVED, not stored: `max(0, level-9) - sum(learned ranks)` (the first point at L10, -//! 1/level to 51 at L60). No `Character` column → no gateway-binding change. -//! -//! BASELINE SAFETY: a character with zero learned-talent rows applies nothing → byte-identical combat. The -//! seeded talent data is inert until a point is spent. The two tables are NEW → `publish` auto-migrates. -//! -//! DEFERRED (this slice): ability-granting talents (Mortal Strike/Bloodthirst/… — need a per-player -//! learnable-spell system; `CASTABLE` is a hardcoded const); the client talent pane (the update-mask -//! descriptor wall — work-item 031's wire/system half); auto-granting points on a mid-session ding -//! (re-derived correctly at the next learn); gossip surfacing of "unlearn talents" at the trainer -//! (work-item 198 follow-up — the reducer is wired, the gateway arm is not). [entity] -//! -//! DEMO SEED (work-item 207): `seed_talents` inserts a small hand-authored Warrior tree (ids 1-8) so a -//! no-DBC sandbox has a talent tree to exercise `learn_talent`/`reset_talents`/the aura pipeline. A real -//! `TalentTab.dbc`/`Talent.dbc` import normally replaces those rows. If imported tabs coexist with a -//! tab-less demo row, the owning learn Gate treats the imported catalogue as authoritative and refuses -//! the demo row. Only a Shard with no `TalentTab` rows may use the Warrior-only demo tree. A character -//! who learned a demo talent before an import keeps an orphaned `game_character_talent` row; -//! `apply_learned_talents` logs and skips it at login. The reserved 51xxx demo spell ids do not collide -//! with imported Spell.dbc ids. +//! Talent definitions, Character selections, and passive aura application. +//! Points derive from level and learned ranks. Login and learning apply the same effect pipeline. use spacetimedb::{table, Identity, ReducerContext, Table}; @@ -66,7 +38,7 @@ pub struct Talent { /// and this is the granted active. END-appended `#[default(0)]` → auto-migrates. [static] #[default(0)] pub grant_spell_id: u32, - // --- Talent.dbc import columns (work-item 207, END-appended, #[default(0)] → additive auto-migration). + // --- Talent.dbc import columns, END-appended, #[default(0)] → additive auto-migration). // Every demo-seeded talent (ids 1-8) leaves these at 0 — byte-identical baseline behavior. /// The real `game_talent_tab.tab_id` this talent belongs to (Talent.dbc `Talent.tab`) — 0 for a /// demo-seeded talent (no real tab). Unlike `tree_id` (which repeats 0/1/2 across EVERY class's tabs), @@ -658,14 +630,6 @@ pub fn apply_learned_talents(ctx: &ReducerContext, guid: u64, owner: Identity, l } } -// =========================================================================================== -// Respec — unlearn every learned talent for an escalating gold cost (work-item 198) -// =========================================================================================== - -/// Gold cost (copper) of a character's NEXT respec, given `respec_count` prior resets: vanilla's -/// `Player::resetTalentsCost` step table — 1g / 5g / 10g, then +5g per further reset, capped at 50g -/// (1.12 has no cost decay — that's a TBC addition). Expressed directly in copper (1g = 10_000c, the -/// server's existing money unit — see `game_trainer_spell.cost`). Pure — unit-tested. pub(crate) fn respec_cost_copper(respec_count: u32) -> u32 { const CAP_COPPER: u32 = 500_000; let copper = match respec_count { diff --git a/module/src/taxi.rs b/module/src/taxi.rs index e50340729..eae6d50ba 100644 --- a/module/src/taxi.rs +++ b/module/src/taxi.rs @@ -134,7 +134,7 @@ pub struct TaxiServiceReply { pub refusal: String, pub created_micros: i64, /// Stable primitive result for activation replies. Status/open callers ignore it. End-appended - /// with a default so publishing over the Ticket 02 mailbox schema is migration-safe. + /// with a default so publishing over the mailbox schema is migration-safe. #[default(0)] pub result_code: u8, } diff --git a/module/src/terrain.rs b/module/src/terrain.rs index 476108362..a60f57a6d 100644 --- a/module/src/terrain.rs +++ b/module/src/terrain.rs @@ -1,4 +1,4 @@ -//! Terrain heightmap: ground-Z lookups for creature + bot movement (work-items 172/173). +//! Terrain heightmap: ground-Z lookups for creature + bot movement. //! //! One row per ADT MCNK cell (33.33 yd square), imported from the operator's own client by //! `importer --terrain` (same licensing firewall as `--dbc`: extracted data never enters the @@ -7,7 +7,7 @@ //! the content slice; `ground_z` returns `None` off-slice and every caller keeps its current //! Z there, so unimported areas behave exactly as before this table existed. -use crate::game_area; // zone_id_at's one-hop parent walk (accessor trait) +use crate::game_area; use lyracore_shared::terrain::{cell_index, cell_key}; use spacetimedb::{reducer, table, ReducerContext, Table}; @@ -35,8 +35,8 @@ pub struct TerrainChunk { } /// Ground height at (x, y), or `None` where no terrain is imported. ONE indexed PK find plus -/// arithmetic — this sits in the 0.5s movement tick, so it must never scan (work-item 173 / -/// de-risking spike #5). The cell math + bilinear interpolation live in +/// arithmetic, this sits in the 0.5s movement tick, so it must never scan / +/// de-risking spike). The cell math + bilinear interpolation live in /// `lyracore_shared::terrain` (unit-tested there; the importer's self-check shares the SAME code, /// so import-time verification covers this exact function). pub fn ground_z(ctx: &ReducerContext, map_id: u32, x: f32, y: f32) -> Option { @@ -50,7 +50,7 @@ pub fn ground_z(ctx: &ReducerContext, map_id: u32, x: f32, y: f32) -> Option Option { let (cx, cy) = (cell_index(x)?, cell_index(y)?); let chunk = ctx @@ -149,12 +149,12 @@ pub fn debug_check_submerged(ctx: &ReducerContext, guid: u64) -> Result<(), Stri /// Chase the position's MCNK `area_id` (`area_id_at` above) ONE hop up `game_area.parent_area_id` to /// its enclosing zone — e.g. a Goldshire subzone area resolves to zone 12 (Elwynn). NOT a full /// recursive area-hierarchy walk (a subzone-of-a-subzone would need more than one hop; deferred to -/// work-item 200, which needs full area resolution for exploration XP anyway). Returns `None` when +///, which needs full area resolution for exploration XP anyway). Returns `None` when /// `game_area` is empty (unimported) or the position's terrain cell has no recorded/imported area, so /// callers skip zone-scoping entirely rather than guessing wrong — a wrong guess would silently narrow /// a candidate set (graveyards, fishing loot) to the WRONG zone. /// -/// The single canonical zone resolver (work-item 209 idiom): `world::graveyard`'s release pick +/// The single canonical zone resolver (idiom): `world::graveyard`'s release pick /// and `loot::apply_fish`'s catch roll both call this instead of each keeping its own one-hop walk. pub fn zone_id_at(ctx: &ReducerContext, map_id: u32, x: f32, y: f32) -> Option { area_at(ctx, map_id, x, y).map(|area| zone_of(&area)) diff --git a/module/src/threat.rs b/module/src/threat.rs index 9f14db683..beb2c2976 100644 --- a/module/src/threat.rs +++ b/module/src/threat.rs @@ -55,8 +55,7 @@ use crate::{game_melee_attack, game_world_entity}; accessor = game_threat, public, index(accessor = by_creature, btree(columns = [creature_guid])), - // Perf catalog 1.17: the source side (clear_for_unit on every death/evade/logout, Feint's - // reduce_threat_from_source) used to full-scan the table. + index(accessor = by_source, btree(columns = [source_guid])) )] pub struct ThreatEntry { @@ -471,10 +470,6 @@ pub fn clear_all(ctx: &ReducerContext) { /// re-points each mob at its new top-threat source (the tank), with zero retarget-code changes. DISTINCT /// from Fade (an `A_MOD_COMBAT(COMBAT_THREAT)` percent on FUTURE threat folded inside `add_threat`); this /// is a one-shot subtraction on the EXISTING rows. A non-positive `amount` is a no-op (never ADDS threat). -/// -/// INDEX DISCIPLINE: the caster's source rows come from the `by_source` btree (perf catalog 1.17 added -/// it; this doc used to say the scan was unavoidable). Still collect the ids first, THEN update — never -/// mutate while iterating. [entity] pub fn reduce_threat_from_source(ctx: &ReducerContext, source_guid: u64, amount: i64) { if amount <= 0 { return; diff --git a/module/src/trade.rs b/module/src/trade.rs index 47dd24843..c5524b4c9 100644 --- a/module/src/trade.rs +++ b/module/src/trade.rs @@ -1,4 +1,4 @@ -//! Player-to-player trading (#8) — the Trade Session handshake. +//! Player-to-player trading, the Trade Session handshake. //! //! A **Trade Session** (see `CONTEXT.md`) is transient module state, the `GroupInvite` template: //! private rows, defensive teardown, never Escrow — after a crash or teardown the trade simply @@ -83,10 +83,10 @@ pub struct TradeSlot { crate::character_owned!(delete, fn sweep_delete_game_trade_slot(ctx, character_guid) { let slots = ctx.db.game_trade_slot(); let owned: Vec = slots - .iter() - .filter(|s| s.owner_guid == character_guid) - .map(|s| s.id) - .collect(); +.iter() +.filter(|s| s.owner_guid == character_guid) +.map(|s| s.id) +.collect(); for id in owned { slots.id().delete(id); } @@ -582,9 +582,6 @@ fn decline_proposal(ctx: &ReducerContext, target_guid: u64, kind: u8) { push_trade_event(ctx, session.initiator_guid, kind, session.target_guid); } -/// One seat's commit-relevant facts — gathered impurely by [`run_trade_commit`], judged -/// purely by [`commit_verdict`] (the transfer-planner lesson: decisions pure, reducers thin). -/// `items_offered` counts TRADED slots only — the Will-Not-Be-Traded Slot never reaches a commit. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct CommitSide { pub guid: u64, @@ -702,9 +699,6 @@ pub(crate) fn apply_unaccept_trade(ctx: &ReducerContext, actor: WorldEntity) -> Ok(()) } -/// One seat's commit facts + the offered instances behind them, gathered in-transaction. -/// A slot row whose item vanished or changed owner is SKIPPED (the push_offer_events self-heal -/// posture) — it simply isn't part of the swap. fn gather_commit_side( ctx: &ReducerContext, session: &TradeSession, diff --git a/module/src/trainer.rs b/module/src/trainer.rs index 7b2de70ad..75332c607 100644 --- a/module/src/trainer.rs +++ b/module/src/trainer.rs @@ -100,7 +100,7 @@ pub(crate) fn profession_already_capped(stored_max_rank: Option, cap: u32) stored_max_rank.is_some_and(|rank| rank as u32 >= cap) } -/// Whether a rank purchase's `game_spell_chain` prerequisite is satisfied (work-item 102, reduced scope): +/// Whether a rank purchase's `game_spell_chain` prerequisite is satisfied (reduced scope): /// `prev_spell == 0` — the family's first rank, or a spell with no chain concept at all — has no /// prerequisite, so it is ALWAYS allowed regardless of `knows_prev`. Otherwise the caster must already /// know `prev_spell` (vanilla refuses training "Fireball Rank 3" while only Rank 1 is known). Pure → @@ -131,22 +131,6 @@ pub(crate) fn trainer_buy_check( Ok(()) } -/// The spell a trainer OFFERING actually teaches. A trainer offers a LearnSpell WRAPPER (Spell.dbc -/// effect 36 → E_SCRIPTED with the real rank in `trigger_spell`); the castable RANK is that trigger — -/// learn THAT so the spellbook + cast resolve a real game_spell row. A self-contained ability (no -/// trigger) is learned as-is; falls back to `spell_id` when the wrapper's effect rows aren't imported -/// (never worse than before). EXCEPTION — a CHANNELED spell (Arcane Missiles) is NOT a wrapper: its -/// `trigger_spell` is the per-tick MISSILE (an A_PERIODIC_TRIGGER effect), not a rank to learn; skip -/// that trigger so the learner gets the channel itself (5143), not the hidden bolt (7268). The same -/// logic excludes every other reactive/at-cast trigger kind — A_FLAG, both Proc kinds (Frost Armor's -/// chill 6136), plain E_TRIGGER (Bloodrage's trickle 29131): those triggers are effect PAYLOADS, not -/// ranks; treating them as wrappers taught the payload instead of the spell (156 review — a Frost -/// Armor R2 buy charged for and learned "Chilled"). A genuine LearnSpell wrapper's effect imports as -/// E_SCRIPTED (never one of the excluded kinds), so it still resolves. This exclusion list MUST stay -/// in lockstep with the importer's `wrapper_to_rank` heuristic (importer/src/spell.rs) — the two are -/// the same rule on the two sides of the wire. Generic over the kind. Shared by `apply_trainer_buy` -/// (the player buy) and a Package's trainer-kit pass — ONE wrapper-resolution -/// chokepoint, so a bot's spellbook and a trained player's can never drift. [entity] fn learn_target_trigger(effect: &crate::SpellEffect) -> Option { (effect.trigger_spell != 0 && effect.kind != crate::spell::A_PERIODIC_TRIGGER @@ -446,11 +430,6 @@ pub(crate) fn reconcile_profile_spell( Ok(true) } -/// Resolve + validate a trainer interaction: `trainer_guid` must be a real in-range TRAINER on `caster`'s -/// own map+instance, and one that SERVES `caster`'s class — a Paladin trainer refuses a Warrior -/// outright. Shared by [`apply_trainer_buy`] and `talent::do_reset_talents` (the respec path, -/// which is trainer-gated identically — its own comment used to read "Same gates as apply_trainer_buy"). -/// Returns the resolved trainer entity. /// /// The `Err` is a bare human detail, not a Refusal tag: `apply_trainer_buy` turns it into a /// [`TrainerRefusal::Unavailable`] and logs the detail, while `do_reset_talents` keeps the plain @@ -475,9 +454,7 @@ pub(crate) fn validate_trainer_interaction( if crate::helpers::dist_sq(caster, &trainer) > TRAINER_RANGE_SQ { return Err("trainer out of range".to_string()); } - // The class gate sits at this shared chokepoint so one guard closes both wrong-class training - // and wrong-class respec. No template row means no gate: missing imported data must not block - // an interaction that used to work. + if ctx .db .game_creature_template() @@ -529,7 +506,7 @@ pub(crate) fn apply_trainer_buy( // idempotent already-known no-op), and on Ok it grants the skill at 1/75. The `learn_skill_line == 0` // arm below is the EXISTING spell path verbatim (byte-identical → no class-spell regression). // - // WEAPON-MASTER FORK (work-item 202): the SAME `learn_skill_line` column shape also carries weapon + // WEAPON-MASTER FORK : the SAME `learn_skill_line` column shape also carries weapon // proficiency offerings (Daggers, Polearm, …) — no new row kind. `is_combat_skill_line` tells the two // apart and the cap/known computation diverges for each: // - PROFESSION cap is the offering's STATIC tier column (`learn_skill_cap`: Apprentice 75, Journeyman @@ -550,10 +527,6 @@ pub(crate) fn apply_trainer_buy( let is_weapon_learn = profession_line != 0 && crate::skill::is_combat_skill_line(profession_line); let (grant, known): (BuyGrant, bool) = if crate::skill::is_riding_skill_line(profession_line) { - // RIDING FORK: a riding offering only belongs on a MOUNTS trainer, so a mis-authored row on a - // class/tradeskill trainer is refused rather than quietly teaching the mount skill. Fail-OPEN on a - // missing creature template, the same posture `validate_trainer_interaction`'s class gate takes — - // un-imported data must never block an interaction that used to work. if ctx .db .game_creature_template() @@ -616,15 +589,7 @@ pub(crate) fn apply_trainer_buy( crate::spell::knows_spell(ctx, caster_guid, to_learn), ) }; - // RANK-PREREQ GATE (work-item 102, reduced scope): a plain class-spell offering (`grant` is - // `BuyGrant::Spell` — professions/weapons have no chain concept) whose resolved rank carries a - // `game_spell_chain` row must have the PREVIOUS rank already known (vanilla refuses training - // "Fireball Rank 3" while only Rank 1 is known). NO chain row (an un-imported spell, or a rank with - // nothing before it) → the gate passes, BYTE-IDENTICAL to before this work item (the 178/212 - // precedent: missing imported data never blocks a purchase that used to succeed). Skipped when the - // spell is ALREADY KNOWN so `trainer_buy_check`'s "already-known first" message-priority contract - // (see its doc) holds even for a rank granted out of order by a debug lever or bot kit — the buy is - // rejected either way; only the message differs (102 review finding). + if !known { if let BuyGrant::Spell(to_learn) = grant { if let Some(chain) = ctx.db.game_spell_chain().spell_id().find(to_learn) { @@ -686,7 +651,7 @@ pub(crate) fn apply_trainer_buy( mod tests { use super::*; - // --- work-item 102 (reduced scope): rank-prereq gate --------------------------------------------- + // --- (reduced scope): rank-prereq gate --------------------------------------------- /// `rank_prereq_met` truth table: `prev_spell == 0` is ALWAYS allowed regardless of `knows_prev` /// (the family's first rank, or a spell with no chain concept — the fn's own NO-ROW-FALLBACK @@ -762,7 +727,7 @@ mod tests { ); } - /// WEAPON-LEARN PRESENCE-KNOWN semantics (work-item 202): unlike a profession offering (known = a CAP + /// WEAPON-LEARN PRESENCE-KNOWN semantics : unlike a profession offering (known = a CAP /// comparison), a weapon offering's "known" is mere ROW PRESENCE — feeding `trainer_buy_check` a bare /// `known` bool either way, so the SAME gate enforces both: a class-seeded weapon line (row present, /// regardless of its stored cap) refuses as already-known; a lacked line (no row at all) proceeds. diff --git a/module/src/transfer/harness.rs b/module/src/transfer/harness.rs index 3b8583e33..6d335665b 100644 --- a/module/src/transfer/harness.rs +++ b/module/src/transfer/harness.rs @@ -1,65 +1,6 @@ -//! The place where the transfer protocol is **executed** rather than scanned. -//! -//! # Why this did not exist -//! -//! `ReducerContext` cannot be constructed in a unit test, so every other test in `module/` is -//! either a pure model or a source scan. That left `export_rows` / `import_rows` / `move_rows` / -//! `import_character_blob` pinned by their TEXT: the review ran 21 mutations against that -//! surface and 17 left the suite green or hung it — including repointing -//! `sweep_transfer_game_item_instance` at `not_transported` (which deletes every character's -//! inventory and gear on every hop) with **468 passed, 0 failed**. -//! -//! # The seam -//! -//! Two ordinary Rust generalisations, no framework: -//! -//! 1. [`move_rows`] / [`export_rows_via`] / [`import_rows_via`] are generic over the CONTEXT type -//! and take the transport registry as a PARAMETER. Production binds `C = ReducerContext` and -//! `crate::CHARACTER_OWNED_TRANSFERS` (`export_rows`, `import_rows`); the harness binds -//! `C = FakeDb` and [`ARMS`]. The loop bodies, the `MANIFEST_EXCLUDE` filter, the codec and the -//! unknown-table refusal are the SAME code either way. -//! 2. **Every step of the protocol** is written against a sink trait over [`ShardLedger`] — -//! `apply_begin`, `apply_import_blob`, `apply_confirm`, `apply_finish_step`/`apply_finish`, -//! `apply_release`, `apply_reap`. `CtxShard` is the one production adapter; [`FakeDb`] is the -//! test one. Every guard each reducer has is executed here against real bsatn blobs built by the -//! real [`build_export_blob`] — including the whole six-step cross-database sequence, driven -//! across TWO `FakeDb`s in -//! `the_six_step_sequence_moves_a_populated_character_between_two_databases`. -//! -//! # The ceiling — what this harness still CANNOT run, and why -//! -//! The ~16 per-table `character_owned!(transfer, ..)` arms themselves. Their expansions are -//! `ctx.db.game_item_instance().by_owner_guid().filter(..)` — real table accessors that only exist -//! on a real `ReducerContext`, and making them generic would mean rewriting every table accessor in -//! the module behind a trait, which is not a testing change but a rewrite of the module. So: -//! -//! * **What is executed here**: the transport plumbing every arm flows through (the codec, the -//! guid it is handed, the registry lookup, the export/import loops, `not_transported`), plus every -//! `apply_*` step body. -//! * **What the generated registry covers**: -//! - each REAL table's arm EXISTS (`every_manifest_table_can_cross_a_database_boundary`) — read -//! off build.rs's generated `CHARACTER_OWNED_TRANSFER_NAMES`, not off source text; -//! - each arm transports rather than declining -//! (`the_not_transported_allowlist_matches_the_arms_that_decline`) — likewise generated. -//! moved this from a 100-line brace-depth parser to the `character_owned!` marker KIND, so -//! "does this arm actually move rows" is a parse-time property now, not a scan's guess; -//! * **The seam's own blind spot** — `CtxShard`, the thin production layer this harness substitutes -//! `FakeDb` for. Nothing here runs any of its methods, and each is a single line whose damage is -//! total: no-op'ing `CtxShard::import_rows` means **no manifest table's rows ever arrive**, and an -//! early `return Ok(())` in a reducer shim means the reducer the gateway calls does nothing at all -//! while every test below still passes. Nothing headless covers that layer; a durable test -//! against a real database is the only check. `begin_transfer`'s and `reap_transfers`' -//! 120-line bodies are `apply_begin`/`apply_reap` now, and this file runs them. -//! * **What is still not covered anywhere headless**: SpacetimeDB's transaction rollback. A real -//! `Err` from `import_character_blob` unwinds every write it made; [`FakeDb`] keeps them. Every -//! refusal test below therefore asserts on the **in-row** — the row whose absence is what -//! actually stops `finish_transfer` from destroying the source copy — and never on "nothing was -//! written". -//! -//! Deliberate simplification: three fake tables, not twenty. The transport is table-agnostic by -//! construction (one opaque `TableRows` per arm), so a fourth fake table would exercise no new line -//! of production code. The three are chosen to cover the three SHAPES an arm can have: transports, -//! transports with a different row type, and declines. +//! Transfer protocol tests through the existing `FakeDb` seam. +//! The Fakes execute shared row transport and protocol bodies across two databases. +//! Durable tests cover reducer shims, real table accessors, and transaction rollback. use super::*; use std::cell::{Cell, RefCell}; @@ -91,9 +32,7 @@ pub struct RelayRow { pub blip: u32, } -/// One "database". `RefCell`, not `Mutex`, on purpose: a re-entrant access PANICS (a named -/// test failure) instead of deadlocking. A hang is not a pass — see the same fix applied to the -/// gateway's `FakeShardDb`. +/// One in-memory database. RefCell turns re-entrant access into a failing test instead of a hang. #[derive(Default)] pub struct FakeDb { chars: RefCell>, @@ -112,17 +51,13 @@ pub struct FakeDb { group_members: RefCell>, /// Parties torn down by `remove_member`'s disband — what the character-owned DELETE sweep /// does to a party when one of its members is deleted, and precisely what - /// `detach_for_transfer` exists to run AHEAD of (AC#4). + /// `detach_for_transfer` exists to run AHEAD of (AC). disbanded: RefCell>, /// `game_character_shard`: the forwarding receipt. CHARACTER-OWNED, so the cascade sweeps /// it — which is why `apply_finish` must record it AFTER the cascade, never before. shard_index: RefCell>, accounts: RefCell>, now: Cell, - /// Makes `insert_character` silently do nothing — the ONE thing a real destination can do - /// that this fake otherwise cannot: accept the call and materialise no row. That is the - /// state `apply_import_blob`'s post-import PROOF exists to catch, and without a way to - /// reach it the proof was pinned only by its own text (a mutation survivor). swallow_inserts: Cell, /// `game_guid_allocator.high_water`: what `bump_guid_high_water` has ratcheted /// it to. Starts at 0 (unseeded), same as a fresh database. @@ -241,9 +176,6 @@ impl FakeDb { db } - /// A source database holding `guid` plus a NEIGHBOUR character whose rows must never - /// travel — the guid filter is the only thing keeping them apart, and a mover handed the - /// wrong guid (or `0`) is a real mutation that used to be invisible. fn populated(guid: u64) -> Self { let db = Self::new(); db.chars @@ -664,7 +596,7 @@ fn rows_named<'a>(blob: &'a ExportBlob, table: &str) -> &'a TableRows { } // ======================================================================================= -// AC 1 — a populated character crosses whole, with the right VALUES +// 1, a populated character crosses whole, with the right VALUES // ======================================================================================= #[test] @@ -705,10 +637,7 @@ fn a_populated_character_crosses_a_database_with_every_row_and_value() { assert_eq!(c.class, want.class); assert_eq!(c.health, want.health, "HEALTH did not travel"); assert_eq!(c.power, want.power, "POWER did not travel"); - assert_eq!( - c.money, want.money, - "MONEY did not travel (#30's DEFER residual)" - ); + assert_eq!(c.money, want.money, "MONEY did not travel"); assert_eq!(c.rested_xp, want.rested_xp, "rested XP did not travel"); assert_eq!(c.resting, want.resting); assert_eq!(c.rested_since_micros, want.rested_since_micros); @@ -720,11 +649,11 @@ fn a_populated_character_crosses_a_database_with_every_row_and_value() { // Durotar wildlife with nothing printed). assert_eq!( c.pending_godmode, want.pending_godmode, - "GODMODE did not travel — the arriving GM rebuilds mortal (work-item 289)" + "GODMODE did not travel" ); assert_eq!( c.pending_run_speed_mult_bp, want.pending_run_speed_mult_bp, - "the `.speed` multiplier did not travel (work-item 289)" + "the `.speed` multiplier did not travel" ); assert_eq!(c.respec_count, want.respec_count); assert_eq!( @@ -734,7 +663,6 @@ fn a_populated_character_crosses_a_database_with_every_row_and_value() { assert_eq!(c.home_map, want.home_map); assert_eq!(c.home_x, want.home_x); assert_eq!(c.zone_id, want.zone_id); - // ...and the DESTINATION overwrites exactly the six positional fields, no more. assert_eq!( c.map_id, DEST.map_id, "the arrival must be on the destination MAP" @@ -804,7 +732,7 @@ fn a_populated_character_crosses_a_database_with_every_row_and_value() { } // ======================================================================================= -// AC 2 — export -> import -> export is a fixed point +// 2, export -> import -> export is a fixed point // ======================================================================================= #[test] @@ -876,11 +804,6 @@ fn export_import_export_produces_an_identical_blob() { // Importing a character ratchets the destination's guid allocator // ======================================================================================= -/// AC#3: an imported character bumps the destination's guid high-water mark, so a -/// `create_character` on THIS database afterward can never hand out the same guid. This is -/// the LOCAL-range half of the fix — `GUID` sits inside `dst`'s own `LOCAL_RANGE`, -/// so the new gate must not regress this property: importing a character that belongs HERE -/// still ratchets exactly as before. #[test] fn importing_a_character_bumps_the_destinations_guid_high_water() { let src = FakeDb::populated(GUID); @@ -950,15 +873,9 @@ fn importing_a_foreign_range_guid_leaves_the_destinations_allocator_untouched() ); } -// ======================================================================================= -// The guards — each one EXECUTED, and each one a mutation that used to survive -// ======================================================================================= - #[test] fn an_import_that_cannot_place_a_table_files_no_in_row() { - // The mutation: `import_rows(..)?` -> `let _ = import_rows(..)`. A partial character is - // committed and the in-row filed anyway, which licenses cascade-deleting the source copy - // the missing rows came from. + // An import failure must leave no partial Character. let src = FakeDb::populated(GUID); let mut blob = export(&src, GUID, XFER, DEST); blob.payload.push(TableRows { @@ -977,15 +894,8 @@ fn an_import_that_cannot_place_a_table_files_no_in_row() { ); } -/// The inverse of the test above and the one the drift contract forgot: a table the -/// registry expects that the PAYLOAD does not carry. This used to import with a clean `Ok(())`, -/// file the in-row, and license `finish_transfer` to destroy the complete source copy of a -/// character that had arrived without that table. #[test] fn a_payload_missing_a_manifest_table_is_refused_and_files_no_in_row() { - // EVERY transported table, one at a time — including the `not_transported` one, whose entry - // is empty but still required: the blob protocol emits it, so its absence means the payload - // was not built by this protocol and nothing about it can be trusted. for absent in TRANSPORTED { let src = FakeDb::populated(GUID); let mut blob = export(&src, GUID, XFER, DEST); @@ -1007,7 +917,7 @@ fn a_payload_missing_a_manifest_table_is_refused_and_files_no_in_row() { !dst.has_in_row(XFER), "an in-row was filed for a payload missing {absent} — that row is what licenses \ finish_transfer to destroy the COMPLETE source copy, so the character would be \ - left with only the partial one (issue #42)" + left with only the partial one" ); } @@ -1259,20 +1169,6 @@ fn a_stale_copy_from_an_earlier_hop_is_wiped_before_the_arrival_lands() { assert!(dst.has_in_row(XFER)); } -/// The wipe above only fired when `has_character(guid)` was true, so an ORPHANED -/// owned row (this guid's OWN item/quest/etc. left behind with no accompanying `game_character` -/// row and no `game_transfer_in` witness — the residual state a table-level witness cannot see, -/// since it is a fact about a DIFFERENT table) survived untouched and a fresh import landed -/// its rows on top. The stale rows in this fixture belong to the arriving Character, so this can -/// only ever collide with the SAME character's own leftover, never a stranger's — real -/// SpacetimeDB would PANIC on the duplicate primary key exactly as `import_character_blob`'s -/// crash trace showed on `game_item_instance`. The fake never panics on a duplicate push, so -/// this is pinned on the resulting ROW COUNT instead: a second copy is exactly as wrong as a -/// panic, just silent here. -/// -/// Mutation target: re-add `if sink.has_character(guid) { .. }` around the cascade call in -/// `apply_import_blob` and this goes red (the orphaned gear row survives alongside the fresh -/// pair below). #[test] fn orphaned_owned_rows_with_no_character_row_are_wiped_before_a_fresh_import_lands() { let src = FakeDb::populated(GUID); @@ -1398,7 +1294,7 @@ fn the_export_loop_hands_each_mover_the_transferring_guid() { assert!( decoded::(&nobody, "harness_gear").is_empty(), "guid 0 owns nothing, so it must export nothing — `export_rows` handing every mover a \ - hardcoded 0 was a #36 mutation that left the whole suite green" + the reaper must use the escrow's actual Character guid" ); assert!( !mine.iter().any(|t| t.table == "game_transfer_out"), @@ -1448,15 +1344,9 @@ fn the_import_loop_refuses_a_table_this_build_has_no_arm_for() { // ----------------------------------------------------------------------------------------- // `apply_finish` — the delete-last body, executed. // -// These four replace the `fn do_finish(` source scan that used to stand in for them. Each -// states the live consequence of the single line it pins; deleting that line turns exactly -// this test red. -// ----------------------------------------------------------------------------------------- const XDEST: (u32, u64) = (36, 7); -/// A source database holding `guid` mid-transfer: its rows, its escrow out-row, and a -/// forwarding receipt still naming where it USED to be. fn finishing(guid: u64, cross_database: bool) -> FakeDb { let db = FakeDb::populated(guid); db.escrow( @@ -1513,7 +1403,7 @@ fn a_same_database_finish_never_cascades_the_shared_character_row() { ); } -/// AC#4: a shard hop is not a departure. +/// a shard hop is not a departure. #[test] fn finish_detaches_the_party_before_the_cascade_so_the_party_survives_the_hop() { let mut db = finishing(GUID, true); @@ -1551,12 +1441,7 @@ fn the_forwarding_receipt_survives_the_cascade_that_would_sweep_it() { assert_eq!( db.receipt(GUID), Some(XDEST), - "the source shard kept no forwarding receipt naming the DESTINATION. Two mutations \ - land here and both are silent live: dropping `record_shard` (the escrow settles and \ - the directory never learns where), and writing it BEFORE the cascade (which sweeps \ - `game_character_shard` — it is character-owned — and wipes the receipt that had just \ - been written). A gateway whose realm-core is unconfigured has nothing but this row to \ - find a character that moved off the shard it is asking." + "the source Shard must retain a forwarding receipt for the destination" ); } @@ -1582,13 +1467,7 @@ fn not_transported_exports_nothing_and_absorbs_anything() { // ----------------------------------------------------------------------------------------- // `apply_begin` — step 1, EXECUTED. // -// Everything below used to be three `.contains()` scans of `begin_transfer`'s 120-line body -// ("it deletes the live entity row", "it calls export_rows", "it calls build_export_blob"). -// A scan cannot tell whether the escrow it wrote is one the destination can actually read. -// ----------------------------------------------------------------------------------------- -/// A source database with the character LIVE on it — the normal shape of a hop: the player is -/// standing in the world when the portal fires. fn beginning(guid: u64) -> FakeDb { let db = FakeDb::populated(guid); db.live.borrow_mut().insert(guid); @@ -1782,7 +1661,6 @@ fn release_drops_the_arrival_fence_but_refuses_on_the_source() { ); apply_release(&mut dst, XFER).expect("a replayed release must answer Ok"); - // ...and on a database that holds the SOURCE claim, release is the wrong call. let mut source = beginning(NEIGHBOUR); apply_begin(&mut source, NEIGHBOUR, NEIGHBOUR, DEST, true).expect("begin"); apply_confirm(&mut source, NEIGHBOUR).expect("confirm"); @@ -1885,11 +1763,7 @@ fn the_six_step_sequence_moves_a_populated_character_between_two_databases() { // ----------------------------------------------------------------------------------------- // `apply_reap` — the recovery net, EXECUTED. // -// The `cross_database && !has_in` rule below used to be pinned by a `.contains()` scan of -// `reap_transfers`' own text, which cannot tell the rule apart from its inverse. -// ----------------------------------------------------------------------------------------- -/// Push the fake's clock past the staleness window, so the next `apply_reap` acts. fn go_stale(db: &FakeDb) { db.now.set(NOW + TRANSFER_STALE_MICROS); } @@ -1923,11 +1797,8 @@ fn the_reaper_rolls_back_a_same_database_escrow_that_provably_never_imported() { ); } -/// **THE cross-database safety rule**, and the one mutation the same-database crash matrix -/// cannot see. An absent in-row here does not mean "not imported" — it means "not yet -/// ATTESTED", because the arrival copy is on another database. Reading it as `Some(false)` -/// rolls the escrow BACK while the destination copy may already be live: a DUPLICATED -/// character, which is unrecoverable. A frozen one is not. +/// An unattested cross-database arrival may already be live. Keep its source frozen +/// until the destination provides an attestation; rollback would duplicate the Character. #[test] fn the_reaper_holds_an_unattested_cross_database_escrow_forever() { let mut src = beginning(GUID); @@ -1957,7 +1828,6 @@ fn the_reaper_rolls_forward_an_attested_escrow_the_driver_abandoned() { let mut src = beginning(GUID); apply_begin(&mut src, XFER, GUID, DEST, true).expect("begin"); apply_confirm(&mut src, XFER).expect("the destination copy is attested durable"); - // ...and the driver dies here, between confirm and finish. go_stale(&src); apply_reap(&mut src); @@ -1990,7 +1860,6 @@ fn finish_before_the_attestation_refuses_and_leaves_the_source_copy_alone() { ZERO durable copies" ); - // ...and on a database with no escrow at all it is a logged NO-OP, not an error: the // gateway fans a finish out across shards and only one of them holds the claim. let mut elsewhere = FakeDb::new(); apply_finish_step(&mut elsewhere, XFER).expect("no escrow here — nothing to finish"); diff --git a/module/src/transfer/mod.rs b/module/src/transfer/mod.rs index d912c5089..321c0c78e 100644 --- a/module/src/transfer/mod.rs +++ b/module/src/transfer/mod.rs @@ -1,150 +1,4 @@ -//! Escrowed character transfer — the ONE primitive behind every sharding granularity ( -//! spec instance entry, continent travel, seam crossings, bulk region migration). -//! -//! # Where the pieces live -//! -//! | file | what is in it | -//! |---|---| -//! | `mod.rs` (here) | the ledger tables, the pure decision core, the SINKS every step is written against, and the reducers that bind them to a `ReducerContext` | -//! | `transport.rs` | the row transport: `RowIo`/`move_rows`/the bsatn codec, the manifest, and `ExportBlob` — everything that answers "what crosses, and in what shape" | -//! | `tests.rs` | the crash matrix, the pure-planner enumerations, and the transport ratchets | -//! | `harness.rs` | `FakeDb` — two in-memory "databases" the real protocol bodies are EXECUTED against | -//! -//! # The protocol -//! -//! ```text -//! begin_transfer import_character finish_transfer -//! Resident ────────────────► Escrowed ────────────────► Imported ────────────────► Resident(dest) -//! src live src frozen src frozen dst live -//! 1 durable 1 durable (src) 2 durable (src+dst) 1 durable (dst) -//! 0 escrow rows out-row out-row + in-row 0 escrow rows -//! │ │ -//! │ reap (stale, not │ reap (stale, imported) -//! │ imported) → ROLLBACK │ → ROLL FORWARD = finish -//! ▼ ▼ -//! Resident(src) Resident(dest) -//! ``` -//! -//! Two rules make every step retryable and every crash recoverable: -//! -//! 1. **Delete-last.** The source's durable copy is destroyed only by `finish_transfer`, and only -//! after the destination's copy is committed (`plan_finish` REFUSES when the in-row is absent). -//! So no reachable state has zero durable copies. -//! 2. **Idempotent import keyed by transfer id.** `game_transfer_in.transfer_id` is the primary -//! key, so a replayed `import_character` is a no-op — a driver that crashed without learning -//! whether its call landed can simply call again. -//! -//! # The in-transit fence, and EXACTLY how far it reaches -//! -//! While either escrow row exists the character is *in transit* — [`is_in_transit`]. Four chokepoints -//! enforce that, and it is worth being precise about which side each one covers, because the epic -//! (the real cross-database move) is built on top of this claim: -//! -//! 1. `helpers::entity_by_owner` — the ACTOR side. Every player-fired reducer resolves "who is -//! acting" through it, so an in-transit character can take no action itself. -//! 2. `world::player_login` — the RE-MATERIALISATION side. The one path that could put a live entity -//! back on a shard the character has left (the dual-liveness dupe). -//! 3. `begin_transfer`'s delete of the live `game_world_entity` row — the TARGET side. The ~50 -//! hand-rolled `map_id`/`instance_id` gates, the aggro candidate scan, the threat lists and the -//! AOI relay all resolve through that row, so they stop seeing the character by construction. -//! 4. `helpers::character_by_guid` / `character_by_name` — the BY-GUID side. Reducers -//! that reach a character by guid or by name straight into `game_character` (or into a -//! character-owned table) touch none of the first three; this pair is the gate they route -//! through, and it reads an in-transit character as ABSENT so each caller's existing -//! "no such character" arm fires (no new error string, no gateway edit). -//! -//! # The by-guid verdict table -//! -//! REFUSE is not the right answer everywhere, so the class is settled per path. Four verdicts exist: -//! -//! * **REFUSE** — route through `character_by_guid`/`character_by_name` (or, for the two background -//! tick passes, carry an [`is_in_transit`] gate directly). The default, and what every fenced -//! reducer in the tree does. -//! * **DEFER into the blob** — `loot::credit_purse`, via [`defer_money_delta`]. Refusing would drop -//! a THIRD party's copper: the recipient is a party member collecting their share of someone -//! else's kill and cannot know why they were shorted. -//! * **REGENERATE at the destination** — `auth::establish_session`, deliberately unfenced. -//! `Character.owner_identity` is per-CONNECTION derived state; a carried copy would arrive stale -//! and be overwritten immediately, and a field that is always wrong on arrival is worse than no -//! field. -//! * **NOT A TRANSFER CONCERN** — the group verbs, settled: party membership is -//! authoritative on realm-core, so there is no source-copy write left to lose. What replaced the -//! fence question is a REPLICATION one, answered by putting `game_group_member` on -//! [`NOT_TRANSPORTED`] and having the gateway re-push realm-core's roster at world entry. -//! -//! The audited exception list is EMPTY: every by-guid path in the tree is either fenced or holds one -//! of the three other verdicts. **The issue-by-issue reasoning for each one — which path, which -//! field it would have lost, why its verdict is what it is — is archived at -//! `docs/history/transfer-by-guid-verdict-table.md`.** Read it before deciding a NEW path's -//! verdict; nothing in it is stale, it is simply not something the protocol's own source has to -//! restate. -//! -//! Enforcement: `tripwires.rs`'s `character_fence_tripwire` is the ratchet that stops a NEW -//! unfenced by-guid path from being added. -//! -//! # The CROSS-DATABASE protocol -//! -//! Shipped the state machine within ONE database (two `instance_id` partitions, so -//! `import_character` re-partitions a row rather than materialising a second). The cross-database work makes the move -//! real: two SpacetimeDB databases, with the gateway carrying the blob between them. The escrow is -//! not forked — the same ledger, the same `plan_*` verdicts, the same reaper — but two facts the -//! same-database deployment gets for free have to be supplied explicitly: -//! -//! **1. The rows have to actually travel.** `ExportBlob` carries `character_row` (the whole -//! `game_character` row, bsatn) plus a `payload` of one `TableRows` per manifest table, produced and -//! consumed by that table's `character_owned!(transfer, ..)` arm. One marker per table, next to the -//! table, exactly like the delete/restamp arms — and -//! `every_manifest_table_can_cross_a_database_boundary` fails if a manifest table has no arm, -//! because a missing arm is silent data LOSS the first time a character crosses. -//! -//! **2. Neither side can see the other's ledger row.** So the step sequence gains two calls, and -//! `cross_database` on the out-row marks the escrow as one where a local ledger read means -//! something different: -//! -//! ```text -//! SOURCE db DESTINATION db -//! begin_transfer ──── blob ────────────────► -//! import_character_blob (materialise + in-row) -//! ◄─── "it committed" ─────── -//! confirm_import (files the SOURCE in-row = the attestation) -//! finish_transfer (cascade-delete the source copy, clear the escrow) -//! ──── "source is gone" ────► -//! release_transfer (drop the in-row → LIVE) -//! ``` -//! -//! - `confirm_import` exists because `finish_transfer` refuses without an in-row, and cross-database -//! the source never gets one from `import_character`. It moves the "is the destination copy -//! durable?" judgement to the gateway — the one component that can see both databases — and -//! nothing downstream changes, because the in-row still means exactly what it meant. -//! - `release_transfer` exists because the destination's in-row is on the destination, so -//! `finish_transfer` (running on the source) cannot clear it. It is called LAST, so the arrival -//! copy stays fenced until the source copy is provably gone. -//! - `reap_transfers` must NOT read a missing in-row as "not imported" for a cross-database escrow — -//! it means "not yet attested". It answers `None` (the documented unconsultable case) and HOLDS. -//! Cross-database escrows therefore never roll back; recovery is forward-only, re-driven by the -//! gateway at the player's next world entry. -//! - `do_finish` cascade-deletes the source copy for a cross-database escrow (there IS a second copy -//! now), which is a no-op same-database by construction. -//! -//! What is still parked: the LIVE two-database run. Every acceptance criterion needs a real -//! 1.12.1 client, two published databases and an operator — What is proven -//! headlessly is the state machine (the crash matrix in `tests.rs`, including the cross-database -//! sequence), the transport ratchet, and the Escrow sequence executed across two `FakeDb`s in -//! `harness.rs`. -//! -//! # Why every step is written against a SINK -//! -//! `ReducerContext` cannot be constructed in a unit test, so before the sinks existed the only thing -//! a test in this crate could do to a reducer body was read its TEXT. The review ran 21 -//! mutations against that surface and 17 left the suite green. Each step therefore has its body -//! written against a trait — [`BeginSink`], [`ImportSink`], [`FinishSink`], [`ReapSink`], all over -//! the shared [`ShardLedger`] — with [`CtxShard`] as the one production adapter and -//! `harness::FakeDb` as the test one. The reducers are two lines each; everything they used to -//! contain is executed for real in `harness.rs`. -//! -//! Everything in the "pure core" section below is `ReducerContext`-free on purpose: it is the whole -//! decision surface of the protocol, so the crash matrix drives the SAME functions the reducers -//! execute. [server] +//! Character Transfer. See `docs/character-transfer.md` for the contract and rationale. mod transport; pub(crate) use transport::*; @@ -285,11 +139,11 @@ crate::character_owned!(delete, fn sweep_delete_game_transfer_out(ctx, character if !crossing { let intents = ctx.db.game_bot_transfer_intent(); let ids: Vec<_> = intents - .by_bot() - .filter(character_guid) - .take(lyracore_shared::transfer::BOT_TRANSFER_PENDING_LIMIT + 1) - .map(|intent| intent.id) - .collect(); +.by_bot() +.filter(character_guid) +.take(lyracore_shared::transfer::BOT_TRANSFER_PENDING_LIMIT + 1) +.map(|intent| intent.id) +.collect(); for id in ids { intents.id().delete(id); } @@ -719,19 +573,8 @@ pub(crate) fn plan_begin( BeginPlan::Escrow } -/// Which character the ledger rows filed under one transfer id name: the SOURCE out-row's, with -/// the DESTINATION in-row's as the fallback. -/// -/// The fallback is not tidiness. Now that the transfer id IS the character guid, a database -/// holding only an unreleased ARRIVAL in-row — the state left by a driver killed between -/// `finish_transfer` and `release_transfer` — would otherwise read as an UNUSED id. `plan_begin` -/// would then answer `Escrow`... except it never gets there, because [`is_in_transit`] sees the -/// in-row and answers `AlreadyInTransit`, so the character is refused a transfer OUT of the shard -/// it is stuck on, permanently, with no operator recourse (the review's blocker 2). Reading the -/// in-row here is what turns that into the `Replay` the `settle_transfer` fence-clear then repairs. -/// -/// Pure, so it is pinned by a real assertion rather than by the reducer's text — the whole line was -/// a mutation survivor. +/// Prefer the source ledger's Character guid, then an unreleased destination receipt. +/// The fallback lets recovery resume an arrival whose source has already finished. pub(crate) fn escrowed_guid(out_row: Option, in_row: Option) -> Option { out_row.or(in_row) } @@ -789,18 +632,8 @@ pub(crate) enum Recovery { RollForward, } -/// The recovery rule, and the single most load-bearing function in this file. -/// -/// `dest_imported` is deliberately an `Option`: **once the destination copy is durable the transfer -/// may only ever roll FORWARD**, so a reaper that cannot establish which side of that line it is on -/// must not guess — guessing rollback against a successful import duplicates the character, and -/// guessing roll-forward against a failed one destroys it. Same-database (this ticket) always -/// answers `Some(..)` by reading the in-row directly. -/// -/// Note: cross-database, the answer comes from the gateway querying the destination -/// shard, and `None` — destination unreachable — holds the escrow indefinitely. That is the correct -/// failure mode: a frozen character is recoverable, a duplicated or deleted one is not. Escalation -/// (alerting an operator on a long-held escrow) is ops tooling, not this primitive. +/// An unknown destination commitment holds the Escrow. Once imported, recovery only rolls forward. +/// Guessing rollback can duplicate the Character; guessing completion can delete its only copy. pub(crate) fn recovery(has_out: bool, dest_imported: Option, age_micros: i64) -> Recovery { if !has_out { return Recovery::Hold; @@ -1013,12 +846,7 @@ pub(crate) trait BeginSink: ShardLedger { fn arm_reaper(&mut self); } -/// What **step 2, cross-database** touches, on the DESTINATION database. -/// -/// This trait IS the seam. `ReducerContext` cannot be constructed in a unit test, so -/// before it existed nothing in the crate could execute the import — its guards were pinned by -/// source scans that matched their own text, and 17 of 21 mutations against them left the suite -/// green. +/// Destination writes used by the shared import operation. pub(crate) trait ImportSink: ShardLedger { /// Is there a LIVE `game_world_entity` for this guid here? fn has_live_entity(&self, guid: u64) -> bool; @@ -1030,7 +858,7 @@ pub(crate) trait ImportSink: ShardLedger { /// The payload half — [`import_rows`] against this database's transport registry. fn import_rows(&mut self, guid: u64, payload: &[TableRows]) -> Result<(), String>; fn ensure_shadow_account(&mut self, account_id: u64); - /// Ratchet THIS database's guid high-water mark up to at least `guid` (AC#3) — so a + /// Ratchet THIS database's guid high-water mark up to at least `guid`, so a /// locally created character can never later collide with one this database received by import. /// Unconditional, same as `world::cascade_delete_character`'s call through this same method — /// the GATE on whether an arriving guid is even allowed to reach here lives in @@ -1045,13 +873,8 @@ pub(crate) trait ImportSink: ShardLedger { fn own_guid_range(&self) -> Option<(u64, u64)>; } -/// What **step 3** touches, on the SOURCE database. -/// -/// The same seam as [`ImportSink`], for the same reason (extended). -/// [`apply_finish`]'s ORDER — detach, then cascade, then write the forwarding receipt, then delete -/// the escrow — was pinned only by source scans that matched their own text, and the ordering -/// constraint is not cosmetic: `game_character_shard` is itself character-owned, so a cascade that -/// ran after `record_shard` would wipe the receipt it had just written. +/// Source completion writes. Cascade precedes the forwarding receipt because the receipt +/// is Character-owned. pub(crate) trait FinishSink: ShardLedger { /// `group::detach_for_transfer` — raw membership removal: no leader transfer, no disband. fn detach_for_transfer(&mut self, guid: u64); @@ -1068,17 +891,7 @@ pub(crate) trait ReapSink: FinishSink { fn escrows(&self) -> Vec<(u64, u64, i64, bool)>; } -/// The ONE production adapter: the real `ReducerContext`, wearing every sink at once. It used to be -/// three separate structs (merged them, so there is one place a `ctx.db` line can be wrong -/// instead of three). -/// -/// **This layer is the seam's own blind spot and there is no headless way to close it.** The harness -/// substitutes a `FakeDb` for every line of it, so a no-op'd method here is invisible to every test -/// in the crate — and cargo-mutants cannot help either, because it can only ask whether a test -/// FAILS and no test can execute a `ReducerContext` at all (measured: 54 missed mutants across this -/// struct on the first full run, which is why `.cargo/mutants.toml` excludes it BY NAME with that -/// number written down). Every method below must remain a single expression; if this stops being a layer of -/// pass-throughs, the harness underneath it stops meaning what it claims. +/// Production implementation of the Transfer Stores. Durable tests exercise these table writes. struct CtxShard<'a> { ctx: &'a ReducerContext, } @@ -1137,11 +950,7 @@ impl BeginSink for CtxShard<'_> { // Persist the live entity into the durable character row FIRST: it is the source copy a // rollback restores, so anything the entity is holding (position, vitals, coin, XP) must be // in it before the entity row goes away. - // - // `set_offline: false` — the CROSS-MAP TELEPORT precedent (`world::teleport_player`), not - // the logout one: a shard hop is a loading screen. `true` would force `pending_ghost` false - // (a free resurrect for a transferring ghost, work-item 226's landmine) and start the - // rested-XP clock. + // Transfers preserve ghost state and do not start the offline rested-XP clock. let entities = self.ctx.db.game_world_entity(); if let Some(e) = entities.guid().find(guid) { crate::world::persist_entity(self.ctx, &e, false); @@ -1294,9 +1103,7 @@ pub fn begin_transfer( ) } -/// The whole of [`begin_transfer`] bar the operator gate, over a [`BeginSink`]. Executed for real by -/// `harness` — before that it was 120 lines of `ReducerContext` code whose only coverage was -/// three `.contains()` scans of its own text. +/// Begin a transfer through the source Store after the caller authorizes the operation. pub(crate) fn apply_begin( sink: &mut S, transfer_id: u64, @@ -1417,12 +1224,7 @@ pub fn import_character(ctx: &ReducerContext, transfer_id: u64) -> Result<(), St // Apply the arrival: re-partition the durable character row onto the destination. The character // stays frozen (the in-row is now also an in-transit fence) until `finish_transfer` releases it. // - // The missing-row arm is a HARD ERROR, never a silent skip: writing the in-row while no - // destination copy materialised is the one way to reach zero durable copies. The in-row is what - // licenses `finish_transfer` (and the reaper's roll-forward) to clear the escrow, so a silent - // skip would settle the transfer with nothing on either side — exactly the loss this ticket - // exists to make unreachable. Returning `Err` aborts the whole transaction, so the in-row below - // is never committed and the escrow stays recoverable (the reaper rolls it BACK). + // // Note: same-database, "materialise the destination copy" IS this re-partition — both // partitions share `game_character`, so there is no second row to create and the guid/unique-name @@ -1713,7 +1515,7 @@ pub(crate) fn apply_import_blob( sink.detach_for_transfer(guid); sink.cascade_delete_character(guid, ListingHolds::Keep); sink.insert_character(c); - // AC#3: ratchet this database's guid allocator past `guid` NOW, in the same + // ratchet this database's guid allocator past `guid` NOW, in the same // transaction as the materialisation — so a `create_character` racing this import (or run any // time after) can never hand the same guid to a brand-new local character. // @@ -1721,23 +1523,14 @@ pub(crate) fn apply_import_blob( // disjoint by construction — a foreign-range arrival (e.g. a world-1 character crossing // into core) can never collide with anything core mints, so ratcheting past it protects // nothing and instead walks core's own mark toward, and eventually past, its own range end. - // That is exactly what was hit live: `lyracore`'s high_water sat at its range end with - // zero local characters above it, and every local `create_character` failed - // GUID_RANGE_EXHAUSTED. A local-range arrival still ratchets, unchanged AC#3. if crate::auth::in_guid_range(sink.own_guid_range(), guid) { sink.bump_guid_high_water(guid); } let payload = payload_for_this_build(&decoded.manifest, &decoded.payload); sink.import_rows(guid, &payload)?; - // The destination has no `game_account` row (accounts are realm-scoped and live on the default - // database until realm-core). `gw::gw_player_login` resolves the account by id, so - // without one the arriving player cannot log in at all. - // Deliberate simplification: a SHADOW account — id + a synthetic username, no credentials. The - // gateway rebinds `identity` through `establish_session` on this shard at every world entry, which is the only - // field this row exists to hold. Upgrade path: realm-core owns accounts (Phase B) and this - // whole arm goes away. Never a login credential: SRP runs on the logon tier against the realm - // database, never here. + // Local Character admission needs a shadow Account for Session ownership. It has no + // credentials; SRP authentication belongs to Realm-core. World entry rebinds its identity. sink.ensure_shadow_account(account_id); // The model says `Apply` ⇒ the destination copy is durable. PROVE it before filing the in-row: @@ -2101,7 +1894,7 @@ pub(crate) fn apply_finish(sink: &mut S, transfer_id: u64) { sink.detach_for_transfer(out.character_guid); sink.cascade_delete_character(out.character_guid, listing_holds_after(&out.blob)); } - // AC#3: the character→shard index entry is written HERE, inside the same transaction that + // the character→shard index entry is written HERE, inside the same transaction that // releases the escrow, from the out-row's own destination fields — so "the escrow settled" and // "the directory says where it settled" can never disagree on this database. // @@ -2153,9 +1946,6 @@ pub fn reap_transfers(ctx: &ReducerContext, _schedule: TransferReaperSchedule) { apply_reap(&mut CtxShard { ctx }); } -/// The whole of [`reap_transfers`] bar the scheduler-identity gate, over a [`ReapSink`]. Executed -/// for real by `harness` — the `cross_database && !has_in` rule below used to be pinned by a -/// `.contains()` scan of the reducer's own text. pub(crate) fn apply_reap(sink: &mut S) { let now = sink.now_micros(); for (transfer_id, character_guid, created_micros, cross_database) in sink.escrows() { diff --git a/module/src/transfer/tests.rs b/module/src/transfer/tests.rs index 64c29fa02..9fdfc84d6 100644 --- a/module/src/transfer/tests.rs +++ b/module/src/transfer/tests.rs @@ -39,15 +39,6 @@ fn manifest_is_the_generated_enumeration_minus_the_machinery() { ); } -/// [`MANIFEST_EXCLUDE`] is the ONLY input to three separate subtractions — the manifest, the -/// export loop, and the arriving payload's required set — and nothing pinned its -/// CONTENTS. Verified by mutation: adding a real character-owned table to it left all 501 -/// module tests green while that table silently vanished from the manifest, was never exported, -/// and was no longer required of an arriving payload. That is the defect reintroduced one -/// name at a time, and it is data loss (a transfer would drop the rows) rather than a missing -/// popup. The test above cannot see it: both sides of its length equation move together. -/// -/// Adding a name here is a DECISION. Make it in this assertion, with the reason. #[test] fn manifest_exclude_holds_only_transfer_machinery() { assert_eq!( @@ -56,7 +47,7 @@ fn manifest_exclude_holds_only_transfer_machinery() { // blob. No table a CHARACTER owns belongs on this list. ["game_transfer_out"], "MANIFEST_EXCLUDE changed. Every name on it is dropped from the transfer manifest, from \ - every export blob, AND from the set an arriving payload must cover (#42) — so a \ + every export blob, AND from the set an arriving payload must cover — so a \ character-owned table added here loses its rows at every shard crossing, silently and \ with no other test failing. Only transfer MACHINERY may be listed." ); @@ -146,14 +137,6 @@ fn hot_marks_name_only_real_manifest_tables() { // Rogue's Stealth presence on the source database — the destination simply never got them. // ------------------------------------------------------------------------------------- -/// Mutation-check per the playbook (§8): reverting `sweep_delete_game_aura` / -/// `sweep_transfer_game_aura` back out (or repointing the transfer arm at `not_transported` -/// without adding `game_aura` to `NOT_TRANSPORTED`) makes THIS assertion fail — `game_aura` -/// would no longer be in the generated manifest, or would no longer be marked hot. The two -/// generic ratchets above (`every_manifest_table_can_cross_a_database_boundary`, -/// `the_not_transported_allowlist_matches_the_arms_that_decline`) only prove a table THAT IS -/// in the manifest is shaped correctly — neither one required `game_aura` to be there at all, -/// which is exactly how the drop went unnoticed. This is the test that would have caught it. #[test] fn aura_rows_are_a_manifest_table_marked_hot() { let m = manifest(); @@ -592,7 +575,6 @@ fn crash_point_e_during_finish_is_retryable() { // Aborted finish == no state change; a retry completes. let retried = step(imported, Step::Finish); assert!(retried.live_dst() && !retried.src_durable && retried.settled()); - // ...and so does the reaper if the driver never retries. assert_eq!(reap_to_fixpoint(imported), retried); } @@ -659,7 +641,6 @@ fn a_transfer_id_reused_for_a_different_character_is_refused_not_replayed() { plan_begin(Some(GUID), GUID + 1, true, false), BeginPlan::IdCollision ); - // ...in the imported phase too — the in-row is consulted as the fallback claim. assert_eq!( plan_begin(Some(GUID), GUID + 1, true, true), BeginPlan::IdCollision @@ -839,15 +820,10 @@ fn the_pure_planners_are_enumerated_over_their_whole_input_space() { assert_eq!(escrowed_guid(None, None), None); assert_eq!(escrowed_guid(Some(7), None), Some(7)); assert_eq!( - escrowed_guid(None, Some(9)), - Some(9), - "a database holding ONLY an unreleased arrival in-row must still read as escrowed for \ - that character. Reading it as an unused id is the permanent login wedge the #36 review \ - found (blocker 2): `is_in_transit` sees the in-row and refuses the hop out, and nothing \ - ever reaps it, because `reap_transfers` iterates game_transfer_out only — so the \ - character is refused a transfer off the shard it is stuck on, permanently, with no \ - operator recourse" - ); + escrowed_guid(None, Some(9)), + Some(9), + "an unreleased arrival must resolve the escrowed Character guid" + ); assert_eq!( escrowed_guid(Some(7), Some(9)), Some(7), @@ -974,14 +950,14 @@ fn folding_a_money_delta_adds_it_to_the_escrowed_blob() { // CROSS-DATABASE: the transport ratchet, and the six-step crash matrix // ------------------------------------------------------------------------------------- -/// THE RATCHET. A character-owned table with no `character_owned!(transfer, ..)` arm does not +/// THE RATCHET. A character-owned table with no `character_owned!(transfer,..)` arm does not /// cross a database boundary — and unlike a missing delete sweep (which leaks rows, loudly, /// forever) that failure is INVISIBLE: the character simply arrives without that table's data, /// and the source copy it came from has already been cascade-deleted. There is no second chance /// and no error anywhere. So: every manifest table must have an arm, and a NEW character-owned /// table fails this test by name in the same edit that adds it. /// -/// "Not transported" is a legal answer — via the `character_owned!(not_transported, ..)` marker +/// "Not transported" is a legal answer, via the `character_owned!(not_transported,..)` marker /// kind, written AT the table (see `rest.rs` / `group.rs`'s invite row) — because a decision /// recorded at the table is a different thing from an omission nobody noticed. /// @@ -1014,13 +990,12 @@ fn every_manifest_table_can_cross_a_database_boundary() { missing.is_empty(), "character-owned table(s) with NO cross-database transport arm: {missing:?}\n\ Their rows would be silently dropped the first time a character moves between two \ - SpacetimeDB databases (issue #19) — the source copy is cascade-deleted by \ + SpacetimeDB databases — the source copy is cascade-deleted by \ finish_transfer, so the data is gone with no error anywhere. Add \ `crate::character_owned!(transfer, fn sweep_transfer_(ctx, guid, io) {{ .. }})` \ next to the table (see any of the existing arms), or `transfer::not_transported(io)` if \ the rows genuinely must not cross — but write the decision AT the table." ); - // ...and no arm may name a table that is not in the manifest (a rename that left the arm's // `sweep_transfer_` name stale would ship rows under a table nobody imports). let tables: Vec = manifest().into_iter().map(|e| e.table).collect(); for t in &transports { @@ -1032,33 +1007,6 @@ fn every_manifest_table_can_cross_a_database_boundary() { } } -/// THE RATCHET'S SECOND HALF, and the one that actually stops silent data loss. -/// -/// `every_manifest_table_can_cross_a_database_boundary` only proves an arm EXISTS. It cannot -/// tell a real transport arm from one that declines to carry anything — so the single edit it is -/// supposed to prevent used to walk straight past it. Verified by mutation: repointing -/// `sweep_transfer_game_item_instance` at `not_transported` left all 468 module tests green -/// while deleting every character's entire inventory and equipped gear on every shard hop. -/// -/// **The mechanism changed.** "Transports" vs "declines" is no longer a property of an -/// arm's BODY that a scanner has to read back out of the source — a transport arm has no body -/// any more. It is the `character_owned!` marker KIND (`transfer` vs `not_transported`), which -/// build.rs already parses, so the mechanical half of the decision arrives here as a generated -/// list. The reasoned half is [`NOT_TRANSPORTED`], where each entry is written out with its -/// justification. This test is the equality between them, and it fails in BOTH directions: a -/// table that stops transporting without being justified, and an allowlist entry that quietly -/// started transporting again (or that names a table which no longer exists). -/// -/// CORE tables only. A `packages/` decline is not on either side of this equality — see -/// [`NOT_TRANSPORTED`]'s doc. What stops a drop-in from declining a CORE table's arm behind this -/// test's back is build.rs, which refuses a second transport arm for a table that already has one. -/// -/// What this replaced was a 100-line brace-depth parser plus a documented adversarial arms race -/// — `contains("move_rows")`, then "exactly once", then "at the top of the arm", then "and its -/// export closure filters by the guid" — each round added after the previous one was defeated by -/// a dead branch. None of those mutations can be written any more: the macro emits the -/// `move_rows` call, the guid filter and the destination insert itself, so an arm that skips any -/// of them does not parse. #[test] fn the_not_transported_allowlist_matches_the_arms_that_decline() { let mut declared: Vec<&str> = NOT_TRANSPORTED.to_vec(); @@ -1110,12 +1058,6 @@ fn every_transported_table_has_exactly_one_arm() { ); } -/// REAL behavioural coverage of the row codec — the half of the transport that actually carries -/// player data, and which had none: every module test is pure or a source scan, so nothing in -/// this crate ever executed `move_rows`. Mutation-testing confirmed the gap (making the import -/// arm inert, so that NO table's rows ever arrive on any transfer, left all 468 tests green). -/// [`encode_rows`]/[`decode_rows`] exist as `ReducerContext`-free halves precisely so this test -/// can drive them. #[test] fn the_row_codec_round_trips_and_refuses_garbage() { let rows = vec![ @@ -1157,7 +1099,6 @@ fn the_row_codec_round_trips_and_refuses_garbage() { "a payload that does not decode must record the failure" ); - // ...and the FIRST failure is the one kept (import_rows walks every table with one outcome). let first = outcome.clone(); let _: Vec = decode_rows(&[0x01], &mut outcome); assert_eq!( @@ -1470,7 +1411,7 @@ fn party_membership_does_not_ride_the_export_blob() { assert!( crate::CHARACTER_OWNED_NOT_TRANSPORTED.contains(&"game_group_member"), "`game_group_member` transports again. Party membership is authoritative on realm-core \ - (#22): the gateway re-pushes the roster onto the destination at world entry, and a \ + the gateway re-pushes the roster onto the destination at world entry, and a \ blob snapshot taken back at `begin_transfer` would overwrite it with the membership the \ character had when it stepped into the portal." ); diff --git a/module/src/transfer/transport.rs b/module/src/transfer/transport.rs index 4f34727eb..a5463c782 100644 --- a/module/src/transfer/transport.rs +++ b/module/src/transfer/transport.rs @@ -2,7 +2,7 @@ //! //! `mod.rs` owns the escrow protocol — when a character may move and what each step is allowed to //! do. This file owns the cargo: the manifest that says WHICH tables travel, the [`RowIo`] direction -//! marker and [`move_rows`] shim every `character_owned!(transfer, ..)` arm flows through, the bsatn +//! marker and [`move_rows`] shim every `character_owned!(transfer,..)` arm flows through, the bsatn //! codec underneath it, and [`ExportBlob`], the one value that crosses the wire. //! //! Everything here is `ReducerContext`-generic or `ReducerContext`-free, which is what lets @@ -19,15 +19,8 @@ use crate::items::{game_item_instance, ItemInstance}; #[path = "legacy_item_rows.rs"] mod legacy_item_rows; -/// The subset of [`crate::CHARACTER_OWNED_TABLES`] marked **hot**: state the destination needs in -/// the player's first frame (worn gear, castable abilities, trained skills, the action bar). The -/// rest is **cold** — correct to stream in behind the loading screen. -/// -/// Deliberate simplification: in v1 the mark is CARRIED but not ACTED ON — one blob ships -/// everything, because same-database transfers have nothing to stream. It becomes load-bearing -/// when the seam-crossing warm handoff has to fit a ~1s budget (spec, Phase C): cold tables -/// move after the handshake. -/// Verified against the generated enumeration by `hot_marks_name_only_real_manifest_tables`. +/// Manifest tables marked hot for first-frame state. The current protocol carries every +/// table in one blob and does not stream cold tables separately. pub(crate) const HOT_TABLES: &[&str] = &[ "game_item_instance", "game_player_action", @@ -47,72 +40,6 @@ pub(crate) const HOT_TABLES: &[&str] = &[ /// export blob is nonsense. pub(crate) const MANIFEST_EXCLUDE: &[&str] = &["game_transfer_out"]; -/// The CORE manifest tables whose rows deliberately do NOT cross a database boundary — the only -/// core tables whose arm may be written with the `character_owned!(not_transported, ..)` marker -/// kind (review). -/// -/// A Package's decline is not listed here and is not cross-checked against this list. This list -/// exists so a core table's decision is reviewable in one place by everyone who reads the core; a -/// drop-in Package is absent from most builds, so naming its tables here would make the equality -/// below depend on which Packages happen to be installed. A Package writes its reason at its own -/// table, where the reader of that table looks for it. -/// -/// The arm-exists ratchet (`every_manifest_table_can_cross_a_database_boundary`) cannot tell a -/// transport arm from a declining one, so on its own it is defeated by the one edit it exists to -/// stop: swapping a real table's arm for a decline keeps the ratchet green while every character -/// silently arrives without that table's rows. Verified by mutation — pointing -/// `sweep_transfer_game_item_instance` at `not_transported` left all 468 module tests passing while -/// deleting every player's gear on every hop. -/// -/// So a core table's "not transported" decision is written HERE, with its REASON, as well as at the -/// table, where build.rs reads it off the marker kind into `CHARACTER_OWNED_NOT_TRANSPORTED`. -/// `the_not_transported_allowlist_matches_the_arms_that_decline` fails if the two disagree in -/// either direction. Each entry needs its reason, exactly like `EXEMPT_ACCESSORS` in `tripwires.rs`: -/// -/// - `game_rest_state_event` — a one-shot relay row with a GC TTL; the DURABLE rest state -/// (`resting` / `rested_xp` / `rested_since_micros`) lives on the character row and travels in -/// `character_row`. -/// - `game_breath_relay_event` — a one-shot timer/damage relay with a GC TTL; private breath -/// state is re-armed from movement after arrival, so carrying a packet would replay stale UI. -/// - `game_breath_state` — transient live-world state. The destination derives a fresh timer from -/// movement after arrival, rather than resuming a source-side underwater snapshot. -/// - `game_group_invite` — a 2-minute dialog whose inviter is by definition not transferring. -/// - `game_pet_command` — the live pet's stay/follow/aggressive state; the pet is a -/// `game_world_entity`, which does not cross, so its command row has nothing to attach to. -/// - `game_group_member` — party membership (group slice). Authoritative on REALM-CORE, so the -/// blob must not carry it: a snapshot taken at `begin_transfer` would race the authority, and it -/// is exactly the snapshot the interim mirror was (a party SPLIT across the boundary could never -/// see itself). The gateway re-pushes realm-core's roster onto the destination at world entry -/// (`sync_group_mirror`), so membership crosses by replication rather than by carriage. -/// - `game_group_member_partition` — the same realm-core roster mirror derives each member's -/// partition. Carrying a source snapshot would bypass its roster and locator revisions, so the -/// gateway re-pushes the certified projection with the membership row. -/// - `game_group_target_icon` — a Target Icon belongs to the Group on the party authority. It names -/// the marked unit, and the unit keeps it across a Transfer with no row to carry. -/// - `game_instance_removal` - an Instance Removal counts down for one instance. A Character that -/// leaves the Instance Pool has left that instance, which ends the countdown -/// (cm:MovementHandler.cpp:133-135), so the source cascade deletes the row and nothing crosses. -/// - `game_mail_delivery` — the mail plane's delivery receipts. They only exist where the -/// authoritative mail rows do, and no character transfers off realm-core. -/// -/// `game_mail_timer` and `game_mail_arrival` stay with the mail rows too, but they are not in -/// this list because they have no marker: the timer names a Mail, not a Character, and the -/// arrival is a short-lived Relay event. A Mail a Transfer imports gets its timer from -/// `debug_repair_after_publish`. -/// -/// - `game_character_shard` — the realm-core character→shard directory. A routing HINT about -/// where the character is, and the blob exists to change that: the snapshot `begin_transfer` takes -/// still names the SOURCE, so carrying it would hand the destination a forwarding receipt pointing -/// back at the shard the character just left. `do_finish` rewrites the source's own row to name the -/// destination, and the authoritative copy on realm-core is the gateway's write. -/// - `game_taxi_service_reply` — a transient request/reply mailbox. A destination recomputes taxi -/// status from the transported discovery rows; carrying an old reply could answer a new request -/// with a source NPC that only existed on the previous shard. -/// - `game_active_taxi_flight` — an in-progress baseline flight is confined to one open-world shard. -/// Carrying it would strand a route whose source NPC and scheduled geometry remain on the source. -// Read only by the ratchet below — it is a written DECISION, kept next to `MANIFEST_EXCLUDE` in -// the file that owns the protocol rather than hidden in `mod tests`, so the next person to reach -// for `not_transported` finds the list they have to justify themselves against. #[cfg_attr(not(test), allow(dead_code))] pub(crate) const NOT_TRANSPORTED: &[&str] = &[ "game_rest_state_event", @@ -215,7 +142,7 @@ pub(crate) fn admit_command_issuer_import( // Cross-database row transport // =========================================================================================== -/// The direction a `character_owned!(transfer, ..)` arm is running in. ONE body serves both, so a +/// The direction a `character_owned!(transfer,..)` arm is running in. ONE body serves both, so a /// table cannot ship rows it does not know how to receive (the drift that would silently drop a /// table's data at the destination). pub enum RowIo<'a> { @@ -260,14 +187,6 @@ pub(crate) fn move_rows( } } -/// The EXPORT codec, split out of [`move_rows`] so it is `ReducerContext`-free and therefore -/// natively testable (`the_row_codec_round_trips_and_refuses_garbage`). Every module test in this -/// crate is pure or a source scan — nothing can run a reducer — so a codec left inside `move_rows` -/// has literally no behavioural coverage, and mutation-testing this file proved exactly that. -/// -/// An unserializable row would silently ship an EMPTY table, and the import-side manifest check -/// cannot tell "no rows" from "lost rows" — so the failure is logged loudly and the buffer left -/// empty rather than half-written. pub(crate) fn encode_rows(rows: Vec) -> Vec where R: spacetimedb::SpacetimeType + spacetimedb::sats::Serialize, @@ -323,7 +242,7 @@ pub(crate) fn not_transported(io: &mut RowIo<'_>) { } } -/// One entry of a transport registry: a table name and the `character_owned!(transfer, ..)` arm +/// One entry of a transport registry: a table name and the `character_owned!(transfer,..)` arm /// that moves its rows. `crate::CHARACTER_OWNED_TRANSFERS` is `&[TransportArm]`; /// the harness supplies its own slice over a fake context. pub(crate) type TransportArm<'a, C> = (&'a str, fn(&C, u64, &mut RowIo<'_>)); @@ -336,16 +255,9 @@ pub(crate) type TransportArm<'a, C> = (&'a str, fn(&C, u64, &mut RowIo<'_>)); /// only seam by which a test in this crate can run the real export body, since the arms themselves /// need a `ReducerContext`. /// -/// **No coverage check here, deliberately** (AC 4). The import side needs one because it -/// consumes a payload from ANOTHER database; this loop MANUFACTURES the payload from the same -/// registry it would check against, pushing one entry per non-excluded arm unconditionally, so -/// "the payload covers the registry" is a tautology no mutation of this body can break without -/// also breaking `a_populated_character_crosses_a_database_with_every_row_and_value` (which pins -/// the payload's table list to `TRANSPORTED`, in registry order). The one export failure that IS -/// real — [`encode_rows`] logging and yielding an empty buffer when a row will not serialize — a -/// coverage check cannot see either, because the entry is present and "no rows" is a legal empty. -/// Catching that needs `RowIo::Export` to carry a `Result`, which is a protocol change, not this -/// issue. +/// Export manufactures one entry per transported registry table. Import checks payload coverage +/// because it receives data from another database. An empty encoded buffer is indistinguishable +/// from a legal empty table, so a coverage check cannot detect a serialization failure here. pub(crate) fn export_rows_via( ctx: &C, character_guid: u64, @@ -373,25 +285,6 @@ pub(crate) fn export_rows(ctx: &ReducerContext, character_guid: u64) -> Vec( ctx: &C, character_guid: u64, @@ -552,7 +445,7 @@ pub(crate) fn import_rows( /// The manifest is the load-bearing SCHEMA half: the destination compares it against its OWN build /// (`manifest()`) and refuses an import from a shard whose character-owned table set differs. The /// `payload` alongside it is the DATA half — the actual rows, one entry per manifest -/// table, produced by that table's `character_owned!(transfer, ..)` arm. +/// table, produced by that table's `character_owned!(transfer,..)` arm. /// /// **Nothing that is already inside `character_row` gets a second field here.** Until then /// the blob ALSO carried `name`/`level`/`map_id`/`instance_id`/`x`/`y`/`z`/`o`/`health`/`power` as @@ -681,8 +574,7 @@ pub(crate) const TRANSFER_TABLES_OLD_BLOBS_MAY_LACK: &[&str] = &[ /// [`TRANSFER_TABLES_OLD_BLOBS_MAY_LACK`] and is otherwise this build's own, entry for entry and in /// order. [`payload_for_this_build`] fills each missing table in as empty. /// -/// Shared by both step-2 reducers. It was two identical inline blocks, and the one in -/// `import_character_blob` was reachable only through a source-scan assertion on its text. +/// Check the arriving manifest against this build's transport registry. pub(crate) fn check_manifest(transfer_id: u64, arriving: &[ManifestEntry]) -> Result<(), String> { let mine = manifest(); let named = |table: &str| arriving.iter().any(|entry| entry.table == table); diff --git a/module/src/tripwires.rs b/module/src/tripwires.rs index 8688ec9c9..1bc62bc21 100644 --- a/module/src/tripwires.rs +++ b/module/src/tripwires.rs @@ -14,13 +14,6 @@ //! //! The spatial and fence scanners share one engine, [`crate::test_scan::raw_table_reads`]. -/// ENFORCEMENT tripwire: `build.rs`'s codegen only registers tables that remembered to -/// add a `character_owned` marker — it can't catch a table that forgot to. This test independently -/// source-scans `src/**/*.rs` for `#[table]` structs carrying a guid field that can name a Character. -/// A table must have a `character_owned` marker that names or reads that exact accessor, or appear in -/// `NOT_CHARACTER_OWNED` with a reason. This prevents one marker in a multi-table file from blessing -/// unrelated tables. Type-specific fields such as `creature_guid` and `item_guid` cannot name a -/// Character and are listed separately in `NON_UNIT_GUID_FIELDS`. #[cfg(test)] pub(crate) mod character_owned_tripwire { use std::fs; @@ -880,10 +873,10 @@ mod partition_discipline_tripwire { // Diagnostics and harness code — never on a gameplay path. A split of the former single // `debug.rs` (budget 9, down from 12) into a directory; the 9 raw scans // landed in two of the seven files — same total, just split along the new file boundary. - ("module/src/debug/mod.rs", 9, "`debug_reducers`-gated test harness; compiled out of production builds entirely (2 of these are #456's `debug_backfill_cell_ids`, a deliberately whole-shard migration sweep — each shard runs it once after the publish that adds the `cell` column)"), - ("module/src/debug/instance.rs", 3, "`debug_reducers`-gated test harness; compiled out of production builds entirely; +2 for #526's `debug_assert_floor_snap`, which scans game_creature_spawn/game_world_entity by `entry` for a guid high-water mark (template-keyed, not a spatial query) before minting a scratch spawn guid"), + ("module/src/debug/mod.rs", 9, "Debug fixture and operator migration reducers inspect the whole Shard, including the packed-cell backfill."), + ("module/src/debug/instance.rs", 3, "Debug fixture setup and floor validation inspect template populations and guid allocation."), // Importers — realm-wide by definition: they wipe and rebuild every partition at once. - ("module/src/creatures/spawn.rs", 4, "`import_creature_spawns` drops every creature entity + spawn row before reloading the world; +1 for `debug_normalize_spawn_timers`, a one-shot operator migration that must visit this database's whole spawn table by definition; +1 for `debug_retire_region_creatures` (#194), which manages the partitioning itself — it scans spawn HOMES to hand a region's population to its owning shard"), + ("module/src/creatures/spawn.rs", 4, "World imports replace all authored spawns. Timer normalization and region retirement inspect the complete spawn catalogue."), ("module/src/go_collider.rs", 1, "Operator reconciliation rebuilds derived colliders for every hosted partition"), ("module/src/gameobject.rs", 2, "`import_gameobjects` drops every gameobject row before reloading the world; plus one by-`template_entry` lookup (an entry-keyed find, not a spatial query — no index on that column)"), ("module/src/package_import/creatures.rs", 1, "a Package Delta apply clears the whole Package creature range before it writes, so a Package that left the enabled set takes its spawns with it — realm-wide reconciliation, and the band has no index"), @@ -896,8 +889,8 @@ mod partition_discipline_tripwire { ("packages/deadmines/src/choreography.rs", 1, "single instance-scoped boss-liveness check inside one encounter"), // Split tick.rs into tick/{mod,movement,lifecycle,sense}.rs; this budget-5 entry // splits with it, same total (3 + 2 = 5), same reasoning as the pre-split note below. - ("module/src/creatures/tick/mod.rs", 3, "per-tick world sweep that locates every player (`active_cell_creatures`, feeding the pets/in_combat/active-cell sets) plus the 2 throttled work-item 230/233 rows-visited counters (`log_active_cell_stats`/`log_narrowed_pass_stats`) — perf-catalog Tier 1 took the OLD combined file from 10 to 7 (1.6/1.7/1.10), and the `timer_never` sentinel took corpse-decay and respawn-due (now in lifecycle.rs) off the list entirely"), - ("module/src/creatures/cycle/ctx.rs", 2, "the aggro phase's sense-tick player snapshot (no players-only index) plus regeneration's health/power candidate read (no `hurt or has a power bar` index) — both moved here verbatim with their passes, the KNOWN DEBT tick/sense.rs used to carry, not new scans"), + ("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)"), // Both nearest-trainer scans now route through `helpers::nearest_entity` (an // indexed `by_map` scan, partition-scoped) — this file's raw-scan count dropped to 0, so @@ -916,12 +909,6 @@ mod partition_discipline_tripwire { tail.starts_with(".iter()") || tail.starts_with(".count()") } - /// Every `(line, accessor)` where a spatial table is read whole — either straight off the - /// accessor (`ctx.db.game_world_entity().iter()`) or off a local handle bound from it - /// (`let entities = ctx.db.game_world_entity();` ... `entities.iter()`). Both forms count: - /// the binding form is the one this codebase actually writes most of the time. Runs on - /// `crate::test_scan::raw_table_reads` — the shared engine this and - /// `character_fence_tripwire::raw_lookups` used to duplicate. fn raw_scans(content: &str) -> Vec<(usize, &'static str)> { crate::test_scan::raw_table_reads(content, SPATIAL_ACCESSORS, opens_whole_table_read) } @@ -1020,14 +1007,13 @@ mod partition_discipline_tripwire { /// The only files allowed to touch a shard column: the one that DEFINES the assignment table /// and the operator reducer that writes it (`region.rs`), and the one that DEFINES the /// per-shard load-sample table and ITS operator reducer (`load.rs`) — `game_shard_load - /// .shard` is a database-name LABEL an ops sample is filed under, ring-evicted and compared for /// equality, exactly like `RegionAssignment.shard`. Both write or compare; neither branches on /// the value to decide anything gameplay-visible. const SHARD_ID_OWNERS: &[&str] = &["module/src/region.rs", "module/src/load.rs"]; /// The ways module code could reach a shard id: the assignment table's accessor, the `.shard` - /// column itself (`row.shard`), and the ROW TYPE — because `let RegionAssignment { shard, .. } - /// = row;` and `match row { RegionAssignment { shard: db, .. } => … }` bind the column without + /// column itself (`row.shard`), and the ROW TYPE, because `let RegionAssignment { shard,.. } + /// = row;` and `match row { RegionAssignment { shard: db,.. } => … }` bind the column without /// ever writing a dot (both forms were confirmed to slip past the first two). You cannot /// destructure a type you may not name. `game_character_shard` / `CharacterShard` deliberately /// do NOT count — that table stores a `(map_id, instance_id)` LOCATION precisely so nothing has @@ -1075,7 +1061,7 @@ mod partition_discipline_tripwire { assert_eq!(shard_id_reads(src), vec![(2, ".shard")]); } - /// AC#1 of **region definitions are data; shard ids are the gateway's business.** The + /// of **region definitions are data; shard ids are the gateway's business.** The /// module stores `region_assignment { map_id, region_id, shard, epoch }` (it has to — realm-core /// is this same wasm under another database name), but the instant a reducer *branches* on /// `shard` the module stops being relocatable: the same code on two databases would take two @@ -1172,13 +1158,6 @@ mod partition_discipline_tripwire { /// it is a ratchet, so it should only ever shrink. `PACKAGE_FILE_BUDGET` carries the same budget /// for an installed Package's file, keyed by file shape instead of by Package, so Core names no /// Package. -/// -// Note: same (file, count) granularity, same text-scan mechanism, and the same two ceilings as -// `partition_discipline_tripwire` — swapping one whitelisted lookup for a different one inside an -// already-whitelisted file slips through, and a handle passed to another fn defeats the scan -// entirely. Upgrade path if it bites: key on the enclosing `fn` name. What it DOES catch is the -// case this ticket exists for: somebody adds a fresh `ctx.db.game_character().guid().find(..)` in -// a file that had none, copying the style of the code next to them, and CI turns red. #[cfg(test)] mod character_fence_tripwire { use crate::test_scan::repo_root; @@ -1192,12 +1171,12 @@ mod character_fence_tripwire { // THE GATE ITSELF. ("module/src/helpers.rs", 2, "`character_by_guid` + `character_by_name` — the fence; these two ARE the raw lookups everything else routes through"), // TRANSFER MACHINERY — must read the row the fence hides, or it could not move it. - ("module/src/transfer/mod.rs", 4, "TRANSFER MACHINERY, all four reading the row the fence hides because that is the row they exist to move: `in_transit_instances` (the instance reaper's claim census — this call site exists BECAUSE the character is in transit, so a fenced reader would return None every time), `import_character`'s same-database re-partition, and `CtxShard`'s two sink methods — `has_character` (the plan probe, the materialise upsert AND the post-import durability PROOF that the destination copy exists before an in-row licenses deleting the source) and `with_character` (the escrow's own export read, taken after the freeze). #380 dropped this from 8 to 4: the other four were expected-shape STRING LITERALS inside exact-shape pins that quoted `CtxImportSink`'s and `helpers.rs`'s bodies verbatim. The `helpers.rs` ones are gone (all three chokepoints collapsed onto one `gate_by_guid`, whose mutants cargo-mutants CATCHES); the adapter's moved to `transfer/tests.rs`, which is why that file now has a budget of its own"), + ("module/src/transfer/mod.rs", 4, "Transfer reads the fenced Character for export, destination materialization, durability checks, and the in-transit Instance census."), ("module/src/world.rs", 8, "player_login carries its own `is_in_transit` fence (the re-materialisation chokepoint, 2 reads); teleport_player, set_home, persist_entity and is_gm_character take a guid already resolved through a fenced entity (persist_entity is called BY begin_transfer); debug_delete_character keeps a raw existence probe so a missing character stays a no-op while an in-transit one is REFUSED, with the fence on the same expression; cascade_delete_character's raw row DELETE is the sweep that probe guards. `recall_to_home` is now FENCED (character_by_guid) — it was the one teleport_player caller needing no live entity"), // OPEN, not decided — spec puts group MEMBERSHIP state on realm-core, settled. - ("module/src/group.rs", 3, "OPEN: group_accept/group_uninvite/group_leave have a THIRD party mutate game_group_member; if #22 lands membership on realm-core these stop being a world shard's concern entirely. Guessing a verdict here is the mistake #30 exists to correct. `push_event`'s own `other_guid` lookup dropped from this count: it could never resolve INVITE's or DECLINE's name on Realm-core (no `game_character` rows there), so the gateway now resolves it from the World Shard caches at render time instead. The join core's two member-identity reads now go through `character_by_guid`"), + ("module/src/group.rs", 3, "Group accept, uninvite, and leave read member identity while changing membership, which is authoritative on Realm-core."), // REGENERATE at the destination — connection-derived state, never carried in the blob. - ("module/src/auth.rs", 4, "REGENERATE: character creation's two name checks (NAME_IN_USE in `check_new_character` and `insert_new_character`'s race-losing retry) predate any character; its guid-allocator seed scan (`legacy_guid_seed_now`, first-ever touch only) needs the whole table; delete_character keeps a raw find so NO_SUCH_CHAR/NOT_OWNER/CHAR_IN_TRANSIT stay three answers, with the fence on the next line. establish_session's owner_identity rebind and `account_has_room`'s per-account cap check now route through the `by_account` index instead of a full scan (issue #390), so they no longer count here"), + ("module/src/auth.rs", 4, "Character creation checks name uniqueness and initializes the guid allocator. Character deletion distinguishes absence, ownership, and the Transfer fence."), // READS, not writes: name/class/race/identity lookups that mutate nothing on the character. ("module/src/items/ops.rs", 2, "race/class reads for the starter loadout and the mana-class gate — no write to the character"), ("module/src/spell/cast/targeting.rs", 1, "caster NAME for the resurrect prompt — no write"), @@ -1250,12 +1229,6 @@ mod character_fence_tripwire { /// indexed point reads; `.iter()` is the case-folding name scan (`character_by_name`'s /// convention) and the account-scoped counts. `.guid().update(` is deliberately NOT here: the /// row it writes was already resolved by one of these, so the READ is the chokepoint. - /// - /// `.guid().delete(` IS here, and is the exception that proves the "the READ is the chokepoint" - /// rule: a raw delete resolves nothing first, so no read gates it. It was added after a review - /// mutant — a bare `ctx.db.game_character().guid().delete(&guid)` dropped into a ZERO-budget - /// file — left the suite green, which is the annihilation case (`delete_character`'s verdict) - /// walking straight past its own ratchet. const LOOKUP_FORMS: &[&str] = &[ ".guid().find(", ".name().find(", @@ -1275,10 +1248,6 @@ mod character_fence_tripwire { LOOKUP_FORMS.iter().any(|f| tail.starts_with(f)) } - /// Every line where `game_character` is read raw — inline off the accessor, or off a local - /// handle bound from it (`let chars = ctx.db.game_character();`, this codebase's dominant idiom). - /// Runs on `crate::test_scan::raw_table_reads` — the shared engine this and - /// `partition_discipline_tripwire::raw_scans` used to duplicate. fn raw_lookups(content: &str) -> Vec { let mut out: Vec = crate::test_scan::raw_table_reads(content, ACCESSOR, opens_raw_lookup) @@ -1505,12 +1474,12 @@ mod gc_reap_tripwire { /// /// Nothing in the type system couples them: `grid_x`, `grid_y` and `cell` are three independent /// columns, and the census behind this tripwire found **24 independent write sites** across 8 files, with no -/// shared constructor for `game_world_entity` re-stamps (7 hand-rolled `e.grid_x = ..` runs) or for +/// shared constructor for `game_world_entity` re-stamps (7 hand-rolled `e.grid_x =..` runs) or for /// `game_gameobject` (9 independent struct literals). Adding a 25th and forgetting the third line /// compiles clean, passes every existing test, and shows up live as entities that vanish. /// -/// So: this scan requires every `grid_x` WRITE — a `grid_x: ..` struct-literal field or a -/// `.grid_x = ..` assignment — to have a `cell` write within the same short window of source +/// So: this scan requires every `grid_x` WRITE, a `grid_x:..` struct-literal field or a +/// `.grid_x =..` assignment, to have a `cell` write within the same short window of source /// text. It is deliberately textual and deliberately local: the point is to fail the build next to /// the line someone just wrote, not to prove a semantic property. #[cfg(test)] @@ -1567,7 +1536,7 @@ pub(crate) mod grid_cell_tripwire { false } - /// `impl` block a `Self { .. }` struct literal at `idx` resolves to. Walks backward for the + /// `impl` block a `Self {.. }` struct literal at `idx` resolves to. Walks backward for the /// nearest `impl` keyword, then takes `impl<..> TypeName` or, for a trait impl, the identifier /// after ` for ` (`impl Trait for TypeName`) — the same rule `Self` follows in the language. /// A near-clone of the `impl<..>` generics-skip is unavoidable here (no shared engine, unlike @@ -1615,10 +1584,10 @@ pub(crate) mod grid_cell_tripwire { /// Walks backward from `idx` tracking `{`/`}` depth to find the nearest enclosing brace, then /// takes the identifier immediately before it (skipping whitespace). Returns that identifier /// only if it looks like a type name (starts uppercase, matching this codebase's convention) — - /// `TypeName { .. }` or `TypeName { ..prev }` — so `None` correctly falls out for a shorthand + /// `TypeName {.. }` or `TypeName {..prev }`, so `None` correctly falls out for a shorthand /// `grid_x,` that is really an ordinary function-CALL argument (`queue_motion(.., grid_x, - /// grid_y, ..)`: its nearest enclosing `{` is a `fn`/`if`/`match` body brace, not preceded by a - /// type name) or any other bare code block. A literal `Self { .. }` constructor resolves through + /// grid_y,..)`: its nearest enclosing `{` is a `fn`/`if`/`match` body brace, not preceded by a + /// type name) or any other bare code block. A literal `Self {.. }` constructor resolves through /// [`resolve_self_type`] to the `impl` block's real type name instead of returning `"Self"` /// verbatim — `struct_has_cell_field` searches for `struct Self`, which never exists. fn enclosing_struct_literal_type(code: &str, idx: usize) -> Option<&str> { @@ -1666,7 +1635,7 @@ pub(crate) mod grid_cell_tripwire { None } - /// `true` if `code` (a single file's text) declares `struct TYPE_NAME { .. pub cell: i64 .. }` + /// `true` if `code` (a single file's text) declares `struct TYPE_NAME {.. pub cell: i64.. }` /// somewhere — i.e. `TYPE_NAME` is one of the cell-bearing rows this tripwire polices. Every /// cell-bearing type's struct literals in this codebase live in the same file as its own `#[table]` /// definition, so a same-file search is enough; a type this can't find (or that genuinely has no @@ -1728,9 +1697,9 @@ pub(crate) mod grid_cell_tripwire { /// a `cell` column (`enclosing_struct_literal_type` + `struct_has_cell_field` — excludes both /// ordinary function-call arguments, whose enclosing brace is a fn/if/match body not a type name, /// and the several event-log tables that carry `grid_x`/`grid_y` with no `cell` column at all, - /// by design). `enclosing_struct_literal_type` resolves a literal `Self { .. }` constructor + /// by design). `enclosing_struct_literal_type` resolves a literal `Self {.. }` constructor /// through `resolve_self_type` to the `impl` block's real type name, so a constructor written as - /// `Self { .. }` inside `impl E { .. }` is policed exactly like `E { .. }` would be. + /// `Self {.. }` inside `impl E {.. }` is policed exactly like `E {.. }` would be. fn grid_x_writes_missing_cell(code: &str) -> Vec { // Assembled at run time — a contiguous literal would match this file's own text. let field_init = format!("{}{}", "grid_x", ": "); @@ -1869,10 +1838,6 @@ pub(crate) mod grid_cell_tripwire { ); } - /// A code-review finding on the shorthand path resolved its enclosing struct-literal type - /// by searching the file for `struct { .. }`, which finds nothing for the near-universal - /// `Self { .. }` constructor idiom (`impl E { fn f() -> Self { Self { .. } } }`) — `Self` is not - /// a struct declaration. Confirms a broken `Self { .. }` shorthand initializer is still CAUGHT. #[test] fn the_scan_catches_a_broken_self_shorthand_initializer() { let bad = "struct E {\n pub grid_x: i32,\n pub grid_y: i32,\n pub cell: i64,\n}\n\ @@ -1891,9 +1856,6 @@ pub(crate) mod grid_cell_tripwire { ); } - /// A code-review finding on `is_own_line` required the ENTIRE trimmed line to be exactly - /// `grid_x,`, so a compact one-liner (`E { grid_x, grid_y }`, multiple fields sharing a line) - /// was skipped outright — violation or not. Confirms a broken compact literal is still CAUGHT. #[test] fn the_scan_catches_a_broken_compact_one_line_shorthand() { let cell_bearing_struct = diff --git a/module/src/vmap.rs b/module/src/vmap.rs index 7b2408041..e0dc2a2fb 100644 --- a/module/src/vmap.rs +++ b/module/src/vmap.rs @@ -86,7 +86,7 @@ pub struct VmapGeneration { pub expected_bytes: u64, /// Immutable 32-byte BLAKE3 digest of the canonical manifest stream. pub manifest_digest: Vec, - /// Immutable canonical identity of the source client data used to plan this generation. + pub source_identity: String, /// Immutable canonical identity of the selected map/cell coverage. pub selection_identity: String, @@ -875,15 +875,6 @@ pub fn probe_rays( ) } -// =========================================================================================== -// Model floor heights — a down-ray probe over the same collision-class triangle store, -// so creature Z-placement/movement can stand on model floors (bridges, WMO interiors like -// Deadmines' decks) that terrain's heightmap knows nothing about (`terrain::ground_z` only -// samples the ADT MCVT grid — the walkable surface UNDER a bridge, not the deck itself). -// =========================================================================================== - -/// How far above `probe_z` the down-ray starts — covers a probe that's already sitting exactly -/// on (or a hair under, from float drift) the floor it should detect. const FLOOR_PROBE_UP_YD: f32 = 2.0; /// How far below `probe_z` the down-ray searches — generous enough to reach the ground floor of /// a multi-deck WMO interior (Deadmines) from a probe standing on an upper deck. diff --git a/module/src/weather.rs b/module/src/weather.rs index ce75c3f04..9b122f862 100644 --- a/module/src/weather.rs +++ b/module/src/weather.rs @@ -46,9 +46,6 @@ const INTENSITY_STEP: f32 = 0.333_333_34; /// Two thirds of the intensity range — the radical branch's drop from heavy back towards light. const RADICAL_STEP: f32 = 0.666_666_7; -/// The spread of one intensity band (light, medium, heavy). CMaNGOS spells this `0.3333` rather than -/// deriving it from [`INTENSITY_STEP`], and the two are deliberately not the same number: a band's -/// spread has to leave room under the `0.3334` and `0.6667` offsets stacked on it. const BAND_WIDTH: f32 = 0.3333; /// A weather type the 1.12.1 client renders. diff --git a/module/src/world.rs b/module/src/world.rs index e93e6d759..c7a8f5fed 100644 --- a/module/src/world.rs +++ b/module/src/world.rs @@ -8,7 +8,7 @@ use lyracore_shared::spatial; use spacetimedb::{reducer, table, Identity, ReducerContext, Table}; use crate::faction::game_faction_template; -// Graveyard resolution (work-item 209/226) lives in `graveyard.rs` (extraction) — this +// Graveyard resolution lives in `graveyard.rs` (extraction), this // alias keeps every `graveyard::...` call site below byte-identical. use crate::graveyard; use crate::helpers::entity_by_owner; @@ -308,16 +308,10 @@ pub struct WorldEntity { // `publish` auto-migrates existing rows (migration rule). #[default(0i64)] pub death_expire_micros: i64, - // Dungeon/instance isolation key (work-item 190 slice 1 — pure plumbing, always 0 this slice): - // 0 = open world; a nonzero value will (slice 2+) identify one `game_instance` row's private - // population. Folded into the by_grid index alongside map_id so spatial scans can never cross - // an instance boundary once slice 2 creates real instances; every entity-vs-entity gate that - // today compares `map_id` gains the matching `instance_id` equality check in the same commit. - // `#[default(0u64)]` (typed — u64 needs 8 bytes) + end-appended so `publish` auto-migrates - // existing rows (migration rule). Gateway-subscribed → hand-synced in world_entity_type.rs. + #[default(0u64)] pub instance_id: u64, - /// GM playtest run-speed multiplier in basis points (10000 = 1.0×, work-item 223's `.speed`). + /// GM playtest run-speed multiplier in basis points (10000 = 1.0×). /// Live-only (NOT threaded through `Character` — a relog resets it to 1×, an accepted /// simplification for a solo-playtest toggle). The gateway relays a change as /// `SMSG_FORCE_RUN_SPEED_CHANGE` (`entity_update_to_outbound`), mirroring the existing aura-based @@ -327,13 +321,7 @@ pub struct WorldEntity { /// column existed). Gateway-subscribed → hand-synced in `world_entity_type.rs` + widened parity. #[default(10000)] pub run_speed_mult_bp: u32, - /// GM playtest godmode (work-item 223's `.god`): INCOMING damage no-ops on a godmode entity - /// (`spell::apply_target_damage` + both melee swing-resolution sites in `combat/mod.rs`); - /// OUTGOING damage is unaffected. Live-only (NOT threaded through `Character`, same rationale as - /// `run_speed_mult_bp` — a relog resets it off). `#[default(false)]` + END-appended so `publish` - /// auto-migrates existing rows to "not godmode" (byte-identical to before this column existed). - /// Gateway-subscribed → hand-synced in `world_entity_type.rs` + widened parity (no wire relay: no - /// client field maps to it, it's a pure server-side gate). + #[default(false)] pub godmode: bool, /// Rest state (196): the LIVE resting flag, so `check_rest_state` can detect an inn threshold @@ -351,7 +339,7 @@ pub struct WorldEntity { /// columns outright (`MAX_EXACT_INDEX_COLS`); range predicates are never index-served at all /// (`IndexProbe::Range` — "we currently never construct this variant") and an `OR` is evaluated /// row-by-row. So the four-column `by_grid` index is unreachable from SQL, and a - /// `grid_x BETWEEN .. AND grid_y BETWEEN ..` box degrades to a full partition scan — 1.1 BILLION + /// `grid_x BETWEEN.. AND grid_y BETWEEN..` box degrades to a full partition scan, 1.1 BILLION /// rows examined on `game_world_entity` in a 445-player measurement, 53% of all writer time. /// Folding the two grid columns into one makes `by_cell` a 3-column all-equality index, which the /// planner CAN serve, and the AOI box becomes 25 point probes instead of a scan. @@ -472,10 +460,6 @@ impl WorldEntity { } } -/// **The per-mover motion row (perf catalog 2.1).** One row per moving entity, UPDATED IN PLACE, -/// carrying the same `(opcode, movement_info)` payload the old per-recipient `game_movement_event` -/// relay table used to carry (dropped; nothing wrote it any more after this table replaced -/// it). /// /// Why it exists: the old table inserted one row PER NEARBY PLAYER per movement, so a crowd cost /// O(C²) event inserts per second through the serialized writer, plus O(C²) rows the reaper deleted @@ -519,7 +503,7 @@ pub struct EntityMotion { /// columns outright (`MAX_EXACT_INDEX_COLS`); range predicates are never index-served at all /// (`IndexProbe::Range` — "we currently never construct this variant") and an `OR` is evaluated /// row-by-row. So the four-column `by_grid` index is unreachable from SQL, and a - /// `grid_x BETWEEN .. AND grid_y BETWEEN ..` box degrades to a full partition scan — 1.1 BILLION + /// `grid_x BETWEEN.. AND grid_y BETWEEN..` box degrades to a full partition scan, 1.1 BILLION /// rows examined on `game_world_entity` in a 445-player measurement, 53% of all writer time. /// Folding the two grid columns into one makes `by_cell` a 3-column all-equality index, which the /// planner CAN serve, and the AOI box becomes 25 point probes instead of a scan. @@ -574,9 +558,6 @@ pub fn movement_violation(dist_2d: f32, dt_s: f32, max_speed: f32) -> Option<(u8 None } -/// Is the character behind `guid` a GM (`gm_level != 0`)? Used to EXEMPT GMs from anti-cheat movement -/// flagging (255) — a GM's `.speed`/`.tele` are legitimate. A non-player guid (creature) has no -/// `game_character` row → `false` (and creatures aren't scored anyway). Mirrors `gm_command`'s lookup. fn is_gm_character(ctx: &ReducerContext, guid: u64) -> bool { ctx.db .game_character() @@ -586,10 +567,6 @@ fn is_gm_character(ctx: &ReducerContext, guid: u64) -> bool { .unwrap_or(false) } -/// One heartbeat's raw position/time delta since the mover's last PERSISTED heartbeat — the anti-cheat -/// scorer's whole input, and what `plan_movement` derives `moved` from. Bundled instead of -/// 7 loose positional floats/u32s (`score_and_log_movement` used to take 9 arguments counting -/// `ctx`/`guid`; `debug_score_movement` builds one of these from its own flat wire args to call it). pub(crate) struct MovementDelta { pub old_x: f32, pub old_y: f32, @@ -602,10 +579,6 @@ pub(crate) struct MovementDelta { } impl MovementDelta { - /// A real translation vs a pure-turn/stationary heartbeat — vanilla breaks a channel on movement, - /// not on turning in place. Both `movement_update` call sites that used to re-derive this - /// independently (`break_channel`'s guard and the anti-cheat scoring guard) now read the one - /// value computed here. pub(crate) fn moved(&self) -> bool { (self.x - self.old_x).powi(2) + (self.y - self.old_y).powi(2) @@ -624,10 +597,6 @@ impl MovementDelta { /// character (`gm_level != 0`) — a GM's `.speed`/`.tele` are legitimate, so flagging them is always a /// false positive. Creatures/godmode/GM return without touching the table. pub(crate) fn score_and_log_movement(ctx: &ReducerContext, guid: u64, delta: &MovementDelta) { - // Perf catalog 1.22: the three table reads (entity re-fetch, GM character row, aura speed fold) - // used to run BEFORE the pure math on every moving heartbeat, even though the check almost always - // returns None. Reordered so the free arithmetic gates them. Byte-identical: the exemptions only - // decide whether a violation gets LOGGED, and nothing is logged when there is no violation. let dist_2d = ((delta.x - delta.old_x).powi(2) + (delta.y - delta.old_y).powi(2)).sqrt(); let dt_s = if delta.move_time_ms > delta.old_move_ms { (delta.move_time_ms - delta.old_move_ms) as f32 / 1000.0 @@ -1040,21 +1009,6 @@ pub(crate) fn apply_player_login( character = chars.guid().find(character_guid).unwrap_or(character); } - // STRANDING GUARD (work-item 190 slice 3, the design's biggest trap): a login whose - // `pending_instance_id` names a REAPED instance (logged out inside, the 30min-empty/reset reap - // ran while offline) must NEVER rebuild into a dead id — the module queries and relay gates - // would isolate them into an empty phantom world. Fall back per `instance::stranding_fallback`: - // a known dungeon map → its entrance const at instance 0 (the design doc §3 rule); a - // non-dungeon map (the dev-map fixture) → in place at instance 0; a dungeon map with no - // entrance arm (pinned-never by test) → hearthstone home. Alive-or-ghost is PRESERVED per - // 226's rules (`pending_ghost` untouched — the ghost branch below still applies): a ghost - // whose corpse was reaped WITH the instance simply has nothing left to reclaim — the - // vanilla-consistent outcome is SPIRIT HEALER ONLY (same as an expired corpse), documented in - // the 190 runbook. The rewritten fields persist via the character update at the end of login. - // NOTE (190 review nit): this checks row EXISTENCE only — a relog into a live instance whose - // reset_requested flag is set rides the condemned instance and, by occupying it, PINS the - // reset until they leave (the reaper requires empty). Safe by construction (never reaped out - // from under a player), just a coherence wrinkle: the resetting leader waits them out. if character.pending_instance_id != 0 && ctx .db @@ -1098,7 +1052,7 @@ pub(crate) fn apply_player_login( // A relog comes back ALIVE (we don't persist ghost state across a REAL logout), so clear any // leftover corpse — else it orphans (rendered with no owning ghost, with a stale reclaim marker // that MSG_CORPSE_QUERY keeps offering for a now-alive player). Idempotent (no-op if none). - // EXCEPTION (work-item 226): `pending_ghost` means this world entry is the rebuild half of a + // EXCEPTION : `pending_ghost` means this world entry is the rebuild half of a // released ghost's despawn (a cross-map graveyard release, or a reconnect that raced the ghost's // corpse run) — the corpse IS the ghost's reclaim target and MUST survive the rebuild, or a // Deadmines death would silently resurrect corpseless at the Westfall graveyard. @@ -1114,7 +1068,7 @@ pub(crate) fn apply_player_login( // connection's bound identity (`ctx.sender()`); `account.id` is identical to `character.account_id` // (gated above), so the builder reads `account_id` from the character row. let mut entity = crate::build_player_entity(ctx, &character, owner); - // Work-item 226: a preserved ghost rebuilds AS a ghost — `build_player_entity` always builds + // a preserved ghost rebuilds AS a ghost, `build_player_entity` always builds // alive (it's the shared player construction, creature-side code), so the released-ghost state // `persist_entity` stamped is re-applied here, the one player-owned call site. Health 1 matches // both `do_repop`'s release and the `persist_entity` clamp (`health.max(1)`). @@ -1165,7 +1119,7 @@ pub(crate) fn apply_player_login( // or ACTIVE racial (Perception) is skipped. BEFORE recompute_vitals so the +stat racial folds into vitals. crate::spell::apply_racial_passives(ctx, character.guid, character.race, character.level); - // Parity #8: fold the now-equipped starter gear's Stamina/Intellect into max HP/mana. The entity was + // Parity: fold the now-equipped starter gear's Stamina/Intellect into max HP/mana. The entity was // built from the bare level curve; grant_starter_item + apply_learned_talents have since equipped gear // and applied talent auras, so re-derive vitals once here (a no-talent newbie still gets its gear HP; // recompute_vitals no-ops if nothing moved). Health may sit a hair under the new max until the first @@ -1261,12 +1215,7 @@ pub(crate) fn cascade_delete_character(ctx: &ReducerContext, character_guid: u64 // allocation comment predicted: a fresh character spawned wearing a despawned bot's // resurrection sickness (live find, 2026-07-19). // - // Auras ON the character used to be hand-deleted here too, for the identical reason - // (`target_guid` is not one of the tripwire's magic field names). The hot-state audit - // gave `game_aura` a real `character_owned!` marker instead (`sweep_delete_game_aura` / - // `sweep_transfer_game_aura`, `spell/tables.rs`) — the delete half above already runs it via - // `CHARACTER_OWNED_DELETE_SWEEPS`, and the marker is also what lets a warm handoff carry the - // rows across a database boundary, which this hand-roll never could. + { use crate::combo::game_combo_point; let combos = ctx.db.game_combo_point(); @@ -1287,13 +1236,7 @@ pub(crate) fn cascade_delete_character(ctx: &ReducerContext, character_guid: u64 // never be re-armed, and nothing ever disengaged it. Despawning 300 bots orphaned ~100 creatures // this way (live, 2026-07-29) and they piled onto the only player left standing. // - // This used to hand-roll `combat::disengage` inline (delete the outgoing row, free - // attackers via `by_target`, clear_target each, `threat::clear_for_unit`) — the same three steps - // the logout path (below) and the cross-map teleport path call the real helper for. The hand-roll - // had drifted: `disengage` ALSO drops IN_COMBAT (+ zeroes `combat_until_ms`) on any attacker left - // with no remaining engagement (the 249 rule), which the copy silently omitted — attackers of a - // deleted character kept the flag set forever. Routing through the canonical helper closes that - // gap and keeps this chokepoint from drifting again. + crate::combat::disengage(ctx, character_guid); ctx.db .game_corpse() @@ -1358,11 +1301,6 @@ pub(crate) const PERSIST_MAX_DRIFT_YD: f32 = 4.0; /// **Does this heartbeat have to be written to the ~60-column entity row?** (perf catalog 2.2) /// -/// `movement_update` used to rewrite the full public row on EVERY heartbeat, and SpacetimeDB then -/// evaluated that row against every in-box subscription and shipped a delta to each. Since 2.1 peers -/// are animated from `game_entity_motion`, not from this row, so most of that work produced nothing -/// any client rendered. -/// /// The row is persisted only when something OBSERVABLE changed: /// /// - **grid cell** — AOI correctness; also the gate `check_area_exploration` rides; @@ -1413,22 +1351,12 @@ pub(crate) fn resolve_environmental_damage(dmg: u32, health: u32) -> (u32, bool) } } -/// What `movement_update` decided to DO with one heartbeat — computed once every impure, -/// DB-touching mutator on the path (exploration/rest, which can flip `resting`/`health`/`xp`) has -/// already run against `mover`. From here on the reducer does nothing but execute this: no further -/// branching on raw movement state happens after `plan_movement` returns. -/// -/// This replaces the reducer's own formatted source as what the "relay is never gated on -/// the persist decision" invariant pins against — a whitespace-collapsed exact-body match that broke -/// on every rename or rustfmt re-wrap. `relay_motion` is unconditionally `true` (see `plan_movement`'s -/// doc) regardless of every other field, which is exactly the shape that regressed once (only the -/// entity row gated; the per-mover motion relay never is) — and it is now an ordinary value-level unit -/// test (`plan_movement`'s tests, below) instead of a source scan. +/// Movement decisions after exploration and rest have updated the mover. +/// Entity persistence may be skipped; motion relay always runs for every accepted heartbeat. pub(crate) struct MovementPlan { /// Write the (possibly-mutated) entity row back — gated on [`snapshot_needs_persist`]. pub persist_entity: bool, - /// Relay this heartbeat's motion to nearby peers via `game_entity_motion`. Always `true` by - /// construction — see the struct doc. + pub relay_motion: bool, /// A lethal fall (see [`resolve_environmental_damage`]): the position already persisted (if /// `persist_entity`), `combat::kill_player` runs next. @@ -1454,9 +1382,6 @@ pub(crate) fn movement_is_accepted( && o.is_finite() } -/// Build the plan `movement_update` executes. Pure — no `ReducerContext` — so every decision this -/// used to make inline (and that the old source-scan pin existed to guard) is now a plain function of -/// its inputs, directly unit-tested below. pub(crate) fn plan_movement( opcode: u16, grid_changed: bool, @@ -1680,10 +1605,6 @@ pub(crate) fn apply_movement_update( score_and_log_movement(ctx, mover_guid, &delta); } - // PER-MOVER MOTION ROW (perf catalog 2.1) — the ONLY movement relay path. Gated on - // `plan.relay_motion` — which `plan_movement`'s own tests prove is ALWAYS `true` — never on - // `plan.persist_entity` or any raw movement state re-derived here: recreating that coupling is - // exactly the regression this used to guard against with a much larger, rename-brittle scan. // // This replaced a 25-probe recipient scan plus one `game_movement_event` INSERT per nearby // player: O(C) writes per heartbeat, so O(C²) per second zone-wide, plus the same again for the @@ -1879,15 +1800,15 @@ pub(crate) fn do_repop(ctx: &ReducerContext, guid: u64) -> Result<(), String> { // The cycle's pet phase ALSO despawns a pet whose owner is dead, so this is belt-and-suspenders (prompt; immediate). crate::creatures::despawn_pets(ctx, player_guid); - // Teleport the ghost to the graveyard `graveyard::resolve_graveyard` (work-item 209) resolves for + // Teleport the ghost to the graveyard `graveyard::resolve_graveyard` resolves for // this death: prefer a zone-linked graveyard (imported `game_graveyard`/`game_graveyard_zone` — // cmangos WorldSafeLocs + game_graveyard_zone), falling back to the nearest of every imported // graveyard on the map, falling back to the five hardcoded Elwynn/Westfall consts // (`graveyard::nearest`) when nothing is imported at all (this sandbox's default state): - // 105 Northshire Abbey (-8935, -188) — zone 12 Elwynn - // 106 Goldshire (-9339, 171) — zone 12 Elwynn - // 854 Eastvale Logging Camp (-9552, -1374) — zone 12 Elwynn - // ≈80 Sentinel Hill (-10650, 1180) [V] — zone 40 Westfall (work-item 206) + // 105 Northshire Abbey (-8935, -188) , zone 12 Elwynn + // 106 Goldshire (-9339, 171) , zone 12 Elwynn + // 854 Eastvale Logging Camp (-9552, -1374) , zone 12 Elwynn + // ≈80 Sentinel Hill (-10650, 1180) [V] , zone 40 Westfall // ≈81 Westfall coast (-11390, 1590) [V] (seed.rs also row-seeds these five, see 209) // The corpse was just inserted at (death_x, death_y) — use those coords + the player's map/race // to resolve the release point, then teleport_player emits MSG_MOVE_TELEPORT_ACK. @@ -1920,7 +1841,7 @@ pub(crate) fn persisted_pending_ghost(dead: bool, player_flags: u32, set_offline pub(crate) const RUN_SPEED_BP_1X: u32 = 10_000; /// What `(pending_godmode, pending_run_speed_mult_bp)` should be stamped onto the durable Character -/// row for this persist — the SCOPE DECISION of work-item 289, in one pure function. +/// row for this persist, the SCOPE DECISION of, in one pure function. /// /// `!set_offline` (a cross-map teleport, a shard-transfer freeze, the stale-entity cleanup in /// `player_login`) CARRIES the live values, because the entity is about to be rebuilt from this row @@ -2008,15 +1929,6 @@ fn spirit_res_vitals(max_health: u32, max_power: u32) -> (u32, u32) { /// healer arg (the healer's guid) is IGNORED like `reclaim_corpse`'s `_corpse_guid`: the res targets /// the actor named by guid (the client only sends this from the healer dialog, which the /// SPIRITHEALER flag already ghost-gates). -/// -/// The `do_spirit_healer_res` body keyed off an explicit player guid — shared by `gw::gw_spirit_res` -/// (resolves the actor guid from the shared connection) and `debug::debug_spirit_healer_res` (a CLI -/// `spacetime call` drives the res by guid, so the feature is verifiable without the mouse-only -/// healer dialog). Gates on the entity -/// being a ghost (mirroring `reclaim_corpse`); res's IN PLACE at 50% health + 50% mana (no range/delay/ -/// corpse check — that's the corpse-run path), clears the ghost/dead state (health > 0 + cleared flags -/// replicate → the client leaves the death screen, exactly like `reclaim_corpse`; vanilla has no -/// "alive" opcode), deletes any leftover corpse, and lands Resurrection Sickness through the aura engine. pub(crate) fn do_spirit_healer_res(ctx: &ReducerContext, guid: u64) -> Result<(), String> { use lyracore_shared::constants::{player_flags, unit_vis_flags}; let entities = ctx.db.game_world_entity(); @@ -2104,14 +2016,11 @@ pub(crate) fn persist_entity(ctx: &ReducerContext, entity: &WorldEntity, set_off // indistinguishable from that and re-trigger the full-heal path). c.health = entity.health.max(1); c.power = entity.power; - // Corpse-reclaim escalation state survives the session boundary — without this, a mere - // disconnect reset the death ladder to 30s (the review's die-die-relog-die exploit). + c.death_expire_micros = entity.death_expire_micros; - // Instance the entity was in survives the session boundary (work-item 190 slice 1, always 0 - // this slice) — the death_expire_micros precedent — so a relog rebuilds into the same instance - // rather than open world. + c.pending_instance_id = entity.instance_id; - // Released-GHOST state survives an entity despawn (work-item 226 — the 224 landmine): a + // Released-GHOST state survives an entity despawn (the 224 landmine): a // cross-map graveyard release persists-then-deletes the entity via `teleport_player`, and // without this stamp the WORLDPORT_ACK rebuild came back ALIVE with no ghost and (via // `player_login`'s corpse delete) no corpse — a silent free resurrect. Re-derived from the @@ -2119,7 +2028,7 @@ pub(crate) fn persist_entity(ctx: &ReducerContext, entity: &WorldEntity, set_off // logout/disconnect (`set_offline`) — the established "relog comes back alive" rule, whose // corpse delete lives in `remove_from_world` right after this persist. c.pending_ghost = persisted_pending_ghost(entity.dead, entity.player_flags, set_offline); - // GM playtest state (work-item 289) survives an entity despawn the same way, and for the same + // GM playtest state survives an entity despawn the same way, and for the same // reason: a cross-map `.tele` (or a cross-database shard hop, whose `begin_transfer` freeze // calls this with `set_offline: false` too) persists-then-deletes the entity, and without this // stamp `build_player_entity` rebuilds it with `.god`/`.speed` silently off. Cleared on a real @@ -2254,7 +2163,7 @@ pub(crate) fn apply_gossip_select( } /// Abrupt socket drop. Wired to SpacetimeDB's lifecycle so an ungraceful disconnect also -/// removes the entity (acceptance criterion #7 for non-clean disconnects). +/// removes the entity (acceptance criterion for non-clean disconnects). #[reducer(client_disconnected)] pub fn on_disconnect(ctx: &ReducerContext) { remove_from_world(ctx, ctx.sender()); @@ -2289,9 +2198,6 @@ mod tests { )); } - /// Each trigger on its own. Mutation target: drop any arm of the `||` and exactly one of these - /// goes red — a heartbeat that silently stops persisting real state is the expensive failure - /// (a stale row is what melee reach, interact and aggro all read). #[test] fn every_observable_change_forces_the_row_write() { assert!( @@ -2401,9 +2307,7 @@ mod tests { move_time_ms: 0, }; // The persist decision is EXACTLY snapshot_needs_persist's, for every trigger that matters — - // and for every one of them, the relay is NEVER gated on the outcome. This second assertion, - // repeated across every persist/no-persist combination below, IS the invariant the old - // source-scan pin existed to guard. + // Motion relay runs for every persistence decision. for &(grid, flags, state, drift) in &[ (false, false, false, 0.0), (true, false, false, 0.0), @@ -2451,7 +2355,7 @@ mod tests { assert!(!plan_movement(HEARTBEAT, false, false, false, 0.0, false, &stationary).moved); } - // ---- Work-item 255: movement plausibility (detect-and-flag) ------------------------------ + // ----: movement plausibility (detect-and-flag) ------------------------------ #[test] fn movement_violation_flags_speed_and_teleport_but_not_legit_motion() { let run = lyracore_shared::constants::speeds::RUN; // 7.0 yd/s @@ -2480,7 +2384,7 @@ mod tests { ); } - // ---- Work-item 224: cross-map teleport decision ------------------------------------------ + // ----: cross-map teleport decision ------------------------------------------ #[test] fn is_cross_map_teleport_only_when_target_map_differs() { @@ -2502,8 +2406,6 @@ mod tests { ); } - // ---- Work-item 226: cross-map ghost preservation (the 224 review-finding-#2 landmine) -------- - #[test] fn pending_ghost_persists_only_a_released_ghost_and_never_across_a_real_logout() { use lyracore_shared::constants::player_flags::GHOST; @@ -2533,11 +2435,8 @@ mod tests { ); } - // ---- Work-item 289: GM playtest state across a cross-map / cross-shard entity rebuild -------- + // ----: GM playtest state across a cross-map / cross-shard entity rebuild -------- - /// The POLICY (behavioural, the decision itself — not a scan): carried across a despawn/rebuild - /// within a session, reset at a session boundary. Mutation targets: flip either branch of - /// `persisted_gm_playtest` and exactly one half of this goes red. #[test] fn gm_playtest_state_carries_across_a_map_change_and_resets_on_a_real_logout() { // The bug: `.god` + `.speed 3`, then `.tele valley` — the cross-map persist (set_offline @@ -2546,7 +2445,7 @@ mod tests { assert_eq!( persisted_gm_playtest(true, 30_000, false), (true, 30_000), - "a cross-map hop carries godmode AND the speed multiplier — dropping them is work-item 289" + "a cross-map hop carries godmode and the speed multiplier" ); // A real logout/disconnect resets both: a GM who forgets `.god` off must not stay // invulnerable (and unaggroable) across sessions with nothing printed. diff --git a/module/src/xp.rs b/module/src/xp.rs index 8351ea213..cf33a0617 100644 --- a/module/src/xp.rs +++ b/module/src/xp.rs @@ -206,8 +206,6 @@ const EXPLORE_LEVEL_BAND: i32 = 5; /// so the award reaches zero about 25 levels over the area, the point where it has gone fully grey. const EXPLORE_GREY_TAPER_PCT_PER_LEVEL: i32 = 5; -/// What fraction (percent) of an area's discovery award survives, for a player `levels_above` the -/// band's top edge. Clamped both ways, so a wildly over-levelled player gets 0 and never a negative. fn explore_grey_taper_pct(levels_above_area: i32) -> u32 { (100 - (levels_above_area - EXPLORE_LEVEL_BAND) * EXPLORE_GREY_TAPER_PCT_PER_LEVEL) .clamp(0, 100) as u32 @@ -396,11 +394,11 @@ pub(crate) fn grant_xp(ctx: &ReducerContext, p: &mut WorldEntity, amount: u32) { if amount == 0 { return; } - // GM playtest `.xprate` (work-item 223): a basis-points multiplier (10000 = 1×) ON TOP of the + // GM playtest `.xprate` : a basis-points multiplier (10000 = 1×) ON TOP of the // realm `xp_rate` (`rated_xp`, already folded in by the caller at each SOURCE — kill XP in // `award_xp`, quest XP in `quest.rs`). Applied HERE, the one chokepoint both sources share, so a // single multiply can never drift between them. u64 math to avoid a u32*u32 overflow. Missing - // config row ⇒ 10000 (1×) — byte-identical to before this feature existed. + // An absent config row uses 10000 (1×). let amount = (amount as u64 * crate::gm::xprate_bp(ctx) as u64) / 10_000; if amount == 0 { return; diff --git a/module/tests/auction_cancel.rs b/module/tests/auction_cancel.rs index 0bf54a2df..6a3b25bf0 100644 --- a/module/tests/auction_cancel.rs +++ b/module/tests/auction_cancel.rs @@ -377,7 +377,7 @@ fn an_unfinished_cancellation_hold_travels_with_its_seller_and_settles_on_the_ne ); let seller = actor(LOCAL_SELLER); - // Phase 1 only: the Gateway stopped before Realm-core decided. + // The Gateway stopped with only the World Shard Hold committed. source.assert_call( "gw_auction_hold_cancel", &["5090092", &seller, &vendor, BID_LISTING, HOUSE, CUT], @@ -453,8 +453,8 @@ fn an_unfinished_cancellation_hold_travels_with_its_seller_and_settles_on_the_ne const HOLD_QUERY: &str = "SELECT * FROM game_auction_hold WHERE operation_id = 5090094"; -/// A Shard where the seller holds a listing of five Tough Jerky, phase 1 only: the Gateway stopped -/// before Realm-core committed it. Returns the Shard, the Hold row and the purse after the deposit. +/// A five-item Tough Jerky listing held on the World Shard before Realm-core commitment. +/// Returns the Shard, Hold row, and purse after the deposit. fn a_held_listing(name: &str) -> (Standalone, BTreeMap, String) { let mut shard = Standalone::start(name); shard.publish_module(); From eeb4550229583d9ccdbf5ef367c2bc92620d1525 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 14:36:45 +0200 Subject: [PATCH 09/21] test: cover reducer and startup wiring through durable fixtures Exercise non-operator Mail refusals, Hunter-pet cleanup and retaming, mount reconstruction, Region assignment tombstones, and aura ID reminting through existing reducer fixtures. Run the real Gateway executable to prove startup leases, Group Intent consumption, roster mirroring, Character deletion, and Transfer completion. The file resolver test now compares the reader result without pinning a function-name string. Co-Authored-By: GPT-6.1 Sol via Codex --- .github/workflows/module-durable.yml | 1 + crates/lyracore-test-support/src/lib.rs | 8 +- gateway/tests/startup_relays.rs | 251 ++++++++++++++++++++++++ module/src/test_scan.rs | 5 +- module/tests/hunter_pet.rs | 132 +++++++++++++ module/tests/item_guids.rs | 58 ++++++ module/tests/mail_operator.rs | 136 +++++++++++++ module/tests/mount.rs | 141 +++++++++++++ module/tests/region_assignment.rs | 31 +++ 9 files changed, 760 insertions(+), 3 deletions(-) create mode 100644 gateway/tests/startup_relays.rs create mode 100644 module/tests/hunter_pet.rs create mode 100644 module/tests/mail_operator.rs create mode 100644 module/tests/mount.rs create mode 100644 module/tests/region_assignment.rs diff --git a/.github/workflows/module-durable.yml b/.github/workflows/module-durable.yml index 97b5debab..2ac857068 100644 --- a/.github/workflows/module-durable.yml +++ b/.github/workflows/module-durable.yml @@ -101,6 +101,7 @@ jobs: cargo test -p lyracore-gateway --bin lyracore-gateway stdb::subscriptions::meeting_stone_durable_tests::seekers_on_two_world_shards_form_one_party_that_reaches_every_mirror -- --ignored --exact cargo test -p lyracore-gateway --bin lyracore-gateway stdb::armor::tests::resistance_projection_adds_property_effects_only_while_the_item_is_worn_and_unbroken -- --ignored --exact cargo test -p lyracore-gateway --test provision_cli provision_writes_the_account_to_the_world_shard_and_realm_core -- --ignored --exact + cargo test -p lyracore-gateway --test startup_relays -- --ignored --test-threads=1 # No Package registers a client command here, so the Module's fixture command carries it. - name: Run the cross-Shard command transport tests diff --git a/crates/lyracore-test-support/src/lib.rs b/crates/lyracore-test-support/src/lib.rs index c78d45429..f23d76c48 100644 --- a/crates/lyracore-test-support/src/lib.rs +++ b/crates/lyracore-test-support/src/lib.rs @@ -326,7 +326,7 @@ impl Standalone { self.assert_ok(&self.call_database(database, reducer, args)); } - pub fn assert_call_anonymous(&self, reducer: &str, args: &[&str]) { + pub fn call_anonymous(&self, reducer: &str, args: &[&str]) -> Output { let mut command = self.command(); command.args([ "call", @@ -337,7 +337,11 @@ impl Standalone { reducer, ]); command.args(args); - self.assert_ok(&command.output().expect("failed to call reducer")); + command.output().expect("failed to call reducer") + } + + pub fn assert_call_anonymous(&self, reducer: &str, args: &[&str]) { + self.assert_ok(&self.call_anonymous(reducer, args)); } pub fn assert_sql(&self, query: &str) { diff --git a/gateway/tests/startup_relays.rs b/gateway/tests/startup_relays.rs new file mode 100644 index 000000000..1a57436bb --- /dev/null +++ b/gateway/tests/startup_relays.rs @@ -0,0 +1,251 @@ +use std::collections::BTreeSet; +use std::fs::{self, File}; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; + +use lyracore_shared::group::realm_op; +use lyracore_test_support::{actor, log_dir, module_bytes, poll_until, Standalone, POLL_TIMEOUT}; + +struct Gateway { + child: Child, + log_path: PathBuf, +} + +impl Gateway { + fn start(node: &Standalone, other: &str, realm: &str) -> Self { + let log_path = log_dir().join(format!("{}-gateway.log", node.shard_name())); + let log = File::create(&log_path).expect("create Gateway log"); + let child = Command::new(env!("CARGO_BIN_EXE_lyracore-gateway")) + .env_clear() + .env("LYRACORE_LOGON_BIND", "127.0.0.1:0") + .env("LYRACORE_WORLD_BIND", "127.0.0.1:0") + .env("LYRACORE_SPACETIMEDB_URL", node.server()) + .env("LYRACORE_DATABASE", node.shard_name()) + .env("LYRACORE_COORDINATOR_TOKEN", node.owner_token()) + .env("LYRACORE_SHARD_MAP", format!("1:*={other}")) + .env("LYRACORE_REALM_CORE", realm) + .env("LYRACORE_GATEWAY_ID", node.shard_name()) + .env("LYRACORE_MAX_BLOCKING_THREADS", "4") + .env("LYRACORE_CALL_PIPES", "1") + .env("RUST_LOG", "info") + .stdin(Stdio::null()) + .stdout(Stdio::from(log.try_clone().expect("clone Gateway log"))) + .stderr(Stdio::from(log)) + .spawn() + .expect("start Gateway"); + Self { child, log_path } + } + + fn wait_for(&mut self, outcome: &str, mut probe: impl FnMut() -> bool) { + let reached = poll_until(POLL_TIMEOUT, || { + assert!( + self.child + .try_wait() + .expect("read Gateway status") + .is_none(), + "Gateway exited before {outcome}:\n{}", + fs::read_to_string(&self.log_path).unwrap_or_default() + ); + probe() + }); + assert!( + reached, + "Gateway did not reach {outcome}:\n{}", + fs::read_to_string(&self.log_path).unwrap_or_default() + ); + } +} + +impl Drop for Gateway { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +fn members(node: &Standalone, shard: &str, group: &str) -> BTreeSet { + node.query_database_rows( + shard, + &format!("SELECT character_guid FROM game_group_member WHERE group_id = {group}"), + ) + .into_iter() + .map(|row| row["character_guid"].clone()) + .collect() +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn gateway_startup_relays_rosters_and_completes_a_transfer_intent() { + let mut node = Standalone::start_persistent("startup-relays-world"); + node.publish_module(); + let source = node.shard_name().to_owned(); + let other = format!("{source}-other"); + let realm = format!("{source}-realm"); + for shard in [&other, &realm] { + node.publish_named_module_bytes(shard, module_bytes()); + } + for shard in [&source, &other, &realm] { + node.assert_call_database(shard, "claim_operator", &[]); + } + node.assert_call("install_guid_range", &["0"]); + node.assert_call_database(&other, "install_guid_range", &["1000000000"]); + node.assert_call_database(&other, "debug_delete_character", &["1"]); + node.assert_call("debug_spawn_player_entity", &["1"]); + node.assert_call( + "create_character", + &["1", "\"Companion\"", "1", "1", "0", "0", "0", "0", "0", "0"], + ); + let created = node.query_rows("SELECT guid FROM game_character WHERE name = 'Companion'"); + assert_eq!(created.len(), 1); + let companion = created[0]["guid"].clone(); + node.assert_call("debug_spawn_player_entity", &[&companion]); + for guid in ["1", companion.as_str()] { + node.assert_call("debug_set_level", &[guid, "20"]); + node.assert_call_database( + &realm, + "set_character_shard", + &[guid, "0", "0", &actor(guid)], + ); + } + + let mut gateway = Gateway::start(&node, &other, &realm); + gateway.wait_for("a lease on every configured Shard", || { + [&source, &other, &realm].into_iter().all(|shard| { + node.query_database_rows(shard, "SELECT id FROM game_gateway_lease") + .len() + == 1 + }) + }); + + node.assert_call( + "debug_emit_sessionless_group_intent", + &["1", &companion, "false"], + ); + gateway.wait_for("a session-less invite executed on Realm-core", || { + node.query_database_rows(&realm, "SELECT character_guid FROM game_group_member") + .len() + == 2 + && node + .query_rows("SELECT id FROM game_bot_invite_intent") + .is_empty() + }); + let groups = node.query_database_rows( + &realm, + "SELECT group_id FROM game_group WHERE leader_guid = 1", + ); + assert_eq!(groups.len(), 1); + let group = &groups[0]["group_id"]; + let revision_query = + format!("SELECT revision, active FROM game_group_roster_revision WHERE group_id = {group}"); + let revision = node.query_database_rows(&realm, &revision_query); + let expected_members = BTreeSet::from(["1".to_owned(), companion.clone()]); + assert_eq!(members(&node, &realm, group), expected_members); + gateway.wait_for("the Realm roster on both World Shards", || { + [&source, &other].into_iter().all(|shard| { + members(&node, shard, group) == expected_members + && node.query_database_rows(shard, &revision_query) == revision + }) + }); + + node.assert_call_database( + &realm, + "realm_group_op", + &[ + &realm_op::LOOT_METHOD.to_string(), + &actor("1"), + "0", + "0", + "2", + "0", + ], + ); + let changed_revision = node.query_database_rows(&realm, &revision_query); + assert_ne!(changed_revision, revision); + let loot_query = + format!("SELECT loot_method, loot_threshold FROM game_group WHERE group_id = {group}"); + gateway.wait_for("changed loot rules on both World Shards", || { + [&source, &other].into_iter().all(|shard| { + let rules = node.query_database_rows(shard, &loot_query); + rules.len() == 1 + && rules[0]["loot_method"] == "0" + && rules[0]["loot_threshold"] == "2" + && node.query_database_rows(shard, &revision_query) == changed_revision + }) + }); + + node.assert_call("debug_delete_character", &["1"]); + gateway.wait_for( + "the deleted Character's party to disband on Realm-core", + || { + let revision = node.query_database_rows(&realm, &revision_query); + members(&node, &realm, group).is_empty() + && revision.len() == 1 + && revision[0]["active"] == "false" + }, + ); + let tombstone = node.query_database_rows(&realm, &revision_query); + assert_eq!(tombstone.len(), 1); + assert_eq!(tombstone[0]["active"], "false"); + gateway.wait_for("the disband on both World Shards", || { + [&source, &other].into_iter().all(|shard| { + members(&node, shard, group).is_empty() + && node + .query_database_rows( + shard, + &format!("SELECT group_id FROM game_group WHERE group_id = {group}"), + ) + .is_empty() + && node.query_database_rows(shard, &revision_query) == tombstone + }) + }); + + node.assert_call( + "debug_bot_transfer", + &[ + &companion, + "1", + "0", + "1200", + "1200", + "50", + "0", + "\"startup relay\"", + ], + ); + gateway.wait_for("a completed Transfer Intent", || { + let arrived = node.query_database_rows( + &other, + &format!("SELECT map_id, pending_instance_id FROM game_character WHERE guid = {companion}"), + ); + let locator = node.query_database_rows( + &realm, + &format!( + "SELECT map_id, instance_id, transfer_pending FROM game_character_shard \ + WHERE character_guid = {companion}" + ), + ); + arrived.len() == 1 + && arrived[0]["map_id"] == "1" + && arrived[0]["pending_instance_id"] == "0" + && locator.len() == 1 + && locator[0]["map_id"] == "1" + && locator[0]["instance_id"] == "0" + && locator[0]["transfer_pending"] == "false" + && node + .query_rows(&format!("SELECT guid FROM game_character WHERE guid = {companion}")) + .is_empty() + && node + .query_rows(&format!( + "SELECT id FROM game_bot_transfer_intent WHERE bot_guid = {companion}" + )) + .is_empty() + && node + .query_database_rows( + &other, + &format!( + "SELECT transfer_id FROM game_transfer_in WHERE character_guid = {companion}" + ), + ) + .is_empty() + }); +} diff --git a/module/src/test_scan.rs b/module/src/test_scan.rs index e7b4a5e69..bf2033794 100644 --- a/module/src/test_scan.rs +++ b/module/src/test_scan.rs @@ -248,7 +248,10 @@ mod tests { fn read_scanned_reads_a_shipped_file() { let src = read_scanned("module/src/test_scan.rs") .expect("module/ is never optional, so this can never be skipped"); - assert!(src.contains("fn read_scanned(")); + assert_eq!( + src, + std::fs::read_to_string(repo_root().join("module/src/test_scan.rs")).unwrap() + ); } /// The typo case the tripwires must keep catching: the directory is installed, the named file diff --git a/module/tests/hunter_pet.rs b/module/tests/hunter_pet.rs new file mode 100644 index 000000000..cf0707c18 --- /dev/null +++ b/module/tests/hunter_pet.rs @@ -0,0 +1,132 @@ +mod support; + +use lyracore_shared::pet::pet_guid_for; +use support::{number, poll_until, Standalone, POLL_TIMEOUT}; + +const HUNTER: &str = "1"; +const BOAR: &str = "51006"; +const TAME: &str = "50300"; + +fn wild_boar(shard: &Standalone) -> String { + let before = shard + .query_rows("SELECT guid FROM game_world_entity WHERE entry = 51006 AND owner_guid = 0"); + shard.assert_call("debug_spawn_at_feet", &[HUNTER, BOAR, "5"]); + let rows = shard + .query_rows("SELECT guid FROM game_world_entity WHERE entry = 51006 AND owner_guid = 0"); + let added: Vec<_> = rows + .into_iter() + .filter(|row| !before.contains(row)) + .collect(); + assert_eq!(added.len(), 1); + added[0]["guid"].clone() +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn tame_dismiss_and_retame_preserve_one_durable_identity_and_clean_live_state() { + let mut shard = Standalone::start("hunter-pet-lifetime"); + shard.publish_module(); + shard.assert_call("claim_operator", &[]); + shard.assert_call("install_guid_range", &["0"]); + shard.assert_call("debug_seed_scenario_fixtures", &[]); + // Entry 51006 also seeds a Flight Master. This private fixture supplies the tameable template. + shard.assert_sql("UPDATE game_creature_template SET creature_family = 5, level = 5, health = 100, npc_flags = 0 WHERE entry = 51006"); + shard.assert_sql("UPDATE game_character SET class = 3, level = 10 WHERE guid = 1"); + shard.assert_call("debug_spawn_player_entity", &[HUNTER]); + shard.assert_call("debug_set_level", &[HUNTER, "60"]); + let wild = wild_boar(&shard); + shard.assert_call("debug_force_cast_at", &[HUNTER, TAME, &wild]); + let pet = pet_guid_for(1).to_string(); + assert!(shard + .query_rows(&format!( + "SELECT guid FROM game_world_entity WHERE guid = {wild}" + )) + .is_empty()); + let live = shard.query_rows(&format!( + "SELECT owner_guid, entry FROM game_world_entity WHERE guid = {pet}" + )); + assert_eq!(live.len(), 1); + assert_eq!(live[0]["owner_guid"], HUNTER); + assert_eq!(live[0]["entry"], BOAR); + let identities = + shard.query_rows("SELECT pet_id, creature_entry FROM game_hunter_pet WHERE owner_guid = 1"); + assert_eq!(identities.len(), 1); + assert_eq!(identities[0]["pet_id"], HUNTER); + let spawn = shard.query_rows(&format!( + "SELECT respawn_at, despawn_at FROM game_creature_spawn WHERE guid = {wild}" + )); + assert_eq!(spawn.len(), 1, "taming preserves the authored spawn"); + let respawn = spacetimedb::Timestamp::parse_from_rfc3339(&spawn[0]["respawn_at"]) + .expect("respawn timestamp") + .to_micros_since_unix_epoch(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_micros() as i64; + assert!( + (now..now + 600_000_000).contains(&respawn), + "taming rearms a finite future respawn" + ); + let despawn = spacetimedb::Timestamp::parse_from_rfc3339(&spawn[0]["despawn_at"]) + .expect("despawn timestamp") + .to_micros_since_unix_epoch(); + assert!( + despawn > now + 100 * 365 * 86_400 * 1_000_000, + "taming disarms the despawn timer" + ); + let protocol = + shard.query_rows("SELECT live_pet_guid FROM game_hunter_pet_protocol WHERE owner_guid = 1"); + assert_eq!( + number::(&protocol[0], "live_pet_guid"), + pet_guid_for(1) + ); + + shard.assert_call("debug_pet_command", &[HUNTER, "117440515", "0"]); + for table in [ + "game_world_entity", + "game_entity_motion", + "game_live_pet_kind", + ] { + let key = if table == "game_live_pet_kind" { + "pet_guid" + } else { + "guid" + }; + assert!( + shard + .query_rows(&format!("SELECT * FROM {table} WHERE {key} = {pet}")) + .is_empty(), + "{table} retains the dismissed pet" + ); + } + assert!(shard + .query_rows("SELECT * FROM game_pet_command WHERE owner_guid = 1") + .is_empty()); + assert_eq!( + shard.query_rows("SELECT pet_id, creature_entry FROM game_hunter_pet WHERE owner_guid = 1"), + identities + ); + + let replacement = wild_boar(&shard); + assert!( + poll_until(POLL_TIMEOUT, || shard + .call("debug_force_cast_at", &[HUNTER, TAME, &replacement]) + .status + .success()), + "retaming must succeed after the global cooldown" + ); + assert_eq!( + shard.query_rows("SELECT pet_id, creature_entry FROM game_hunter_pet WHERE owner_guid = 1"), + identities + ); + assert_eq!( + shard.query_rows("SELECT * FROM game_live_pet_kind").len(), + 1 + ); + let protocol = + shard.query_rows("SELECT live_pet_guid FROM game_hunter_pet_protocol WHERE owner_guid = 1"); + assert_eq!( + number::(&protocol[0], "live_pet_guid"), + pet_guid_for(1) + ); +} diff --git a/module/tests/item_guids.rs b/module/tests/item_guids.rs index 9181a9034..3978287f7 100644 --- a/module/tests/item_guids.rs +++ b/module/tests/item_guids.rs @@ -243,6 +243,64 @@ fn transfer_refuses_legacy_collision_then_preserves_both_formats_on_round_trip() ); } +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn transfer_remints_an_aura_id_without_changing_a_foreign_aura() { + const TRANSFER: &str = "5090173"; + const FOREIGN: &str = "16777217"; + let source = fixture("aura-guid-transfer-source"); + let destination = fixture_in_range("aura-guid-transfer-destination", "1000000000"); + for (shard, guid) in [(&source, "1"), (&destination, FOREIGN)] { + shard.assert_call("debug_set_level", &[guid, "20"]); + shard.assert_sql("UPDATE game_spell SET duration_ms = 3600000 WHERE spell_id = 50072"); + shard.assert_call("debug_force_cast_at", &[guid, "50072", guid]); + } + + let departing = + source.query_rows("SELECT * FROM game_aura WHERE target_guid = 1 AND spell_id = 50072"); + assert_eq!(departing.len(), 1); + destination.assert_sql(&format!( + "UPDATE game_aura SET id = {} WHERE target_guid = {FOREIGN} AND spell_id = 50072", + departing[0]["id"] + )); + let foreign = destination.query_rows(&format!( + "SELECT * FROM game_aura WHERE target_guid = {FOREIGN} AND spell_id = 50072" + )); + assert_eq!(foreign.len(), 1); + assert_eq!(foreign[0]["id"], departing[0]["id"]); + + let blob = freeze_character(&source, TRANSFER, "1"); + destination.assert_sql("DELETE FROM game_world_entity WHERE guid = 1"); + let operator = r#"{"guid":0,"ownership":null}"#; + destination.assert_call("import_character_blob", &[TRANSFER, &blob, operator]); + let arriving = destination + .query_rows("SELECT * FROM game_aura WHERE target_guid = 1 AND spell_id = 50072"); + assert_eq!(arriving.len(), 1); + assert_ne!(arriving[0]["id"], foreign[0]["id"]); + let mut expected = departing[0].clone(); + let mut actual = arriving[0].clone(); + expected.remove("id"); + actual.remove("id"); + assert_eq!(actual, expected); + assert_eq!( + destination.query_rows(&format!( + "SELECT * FROM game_aura WHERE target_guid = {FOREIGN} AND spell_id = 50072" + )), + foreign + ); + + destination.assert_call("import_character_blob", &[TRANSFER, &blob, operator]); + finish(&source, &destination, TRANSFER); + assert_eq!( + destination + .query_rows("SELECT * FROM game_aura WHERE target_guid = 1 AND spell_id = 50072"), + arriving + ); + assert!(source + .query_rows("SELECT * FROM game_aura WHERE target_guid = 1") + .is_empty()); +} + #[test] #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn item_and_character_creation_share_exhaustion_without_changing_partial_stacks() { diff --git a/module/tests/mail_operator.rs b/module/tests/mail_operator.rs new file mode 100644 index 000000000..b2bcf84e2 --- /dev/null +++ b/module/tests/mail_operator.rs @@ -0,0 +1,136 @@ +mod support; + +use support::{actor, Standalone}; + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn mail_requests_refuse_a_non_operator_before_touching_durable_state() { + let mut shard = Standalone::start("mail-operator-boundary"); + shard.publish_module(); + shard.assert_call("claim_operator", &[]); + shard.assert_call("install_guid_range", &["0"]); + shard.assert_call("debug_spawn_player_entity", &["1"]); + let request_actor = actor("1"); + let requests: &[(&str, &[&str])] = &[ + ("realm_mail_mark_read", &[&request_actor, "1"]), + ( + "realm_mail_send", + &[ + &request_actor, + "1", + "\"boundary letter\"", + "\"boundary letter\"", + "1", + "1", + "1", + "false", + ], + ), + ("realm_mail_take_item", &[&request_actor, "1"]), + ("realm_mail_take_money", &[&request_actor, "1"]), + ("realm_mail_item_room", &[&request_actor]), + ("realm_mail_delete", &[&request_actor, "1"]), + ("realm_mail_return", &[&request_actor, "1", "false"]), + ( + "realm_mail_fence", + &[ + "1", + &request_actor, + "1", + "\"boundary letter\"", + "\"boundary letter\"", + "1", + "1", + "1", + "1", + "1", + "false", + ], + ), + ( + "realm_mail_commit", + &[ + "1", + &request_actor, + "1", + "\"boundary letter\"", + "\"boundary letter\"", + "1", + "1", + "1", + "1", + "1", + "false", + "1", + "1", + "1", + "1", + "1", + "1", + "1", + "1", + ], + ), + ( + "realm_mail_take_money_fence", + &["1", &request_actor, "1", "1"], + ), + ("realm_mail_payout", &["1", &request_actor, "1", "1"]), + ( + "realm_mail_take_item_fence", + &["1", &request_actor, "1", "1"], + ), + ( + "realm_mail_item_payout", + &[ + "1", + &request_actor, + "1", + "1", + "1", + "1", + "1", + "false", + "1", + "1", + ], + ), + ("realm_mail_confirm_delivery", &["1", &request_actor]), + ("realm_mail_settle", &["1", &request_actor]), + ("realm_mail_copy_text", &[&request_actor, "1"]), + ("gw_mail_grant_letter", &[&request_actor, "1"]), + ("realm_mail_mark_letter_granted", &[&request_actor, "1"]), + ]; + let tables = [ + "game_mail", + "game_mail_escrow", + "game_item_instance", + "game_item_text", + ]; + let before: Vec<_> = tables + .iter() + .map(|table| shard.query_rows(&format!("SELECT * FROM {table}"))) + .collect(); + let purse = shard.query_rows("SELECT money FROM game_world_entity WHERE guid = 1"); + for (reducer, args) in requests { + let refused = shard.call_anonymous(reducer, args); + let text = format!( + "{}{}", + String::from_utf8_lossy(&refused.stdout), + String::from_utf8_lossy(&refused.stderr) + ); + assert!(!refused.status.success(), "{reducer}: {text}"); + assert!(text.contains("operator only"), "{reducer}: {text}"); + for (table, expected) in tables.iter().zip(&before) { + assert_eq!( + &shard.query_rows(&format!("SELECT * FROM {table}")), + expected, + "{reducer} changed {table}" + ); + } + assert_eq!( + shard.query_rows("SELECT money FROM game_world_entity WHERE guid = 1"), + purse + ); + } +} diff --git a/module/tests/mount.rs b/module/tests/mount.rs new file mode 100644 index 000000000..c2b76a6a2 --- /dev/null +++ b/module/tests/mount.rs @@ -0,0 +1,141 @@ +mod support; + +use support::{actor, number, poll_until, Row, Standalone, POLL_TIMEOUT}; + +const RIDER: &str = "1"; +const REINS: &str = "5090054"; +const MOUNT: &str = "50310"; + +fn fixture(name: &str) -> Standalone { + let mut shard = Standalone::start(name); + shard.publish_module(); + shard.assert_call("claim_operator", &[]); + shard.assert_call("install_guid_range", &["0"]); + shard.assert_call("debug_seed_scenario_fixtures", &[]); + shard.assert_call("debug_spawn_player_entity", &[RIDER]); + shard.assert_call("debug_set_level", &[RIDER, "20"]); + shard.assert_call("debug_set_money", &[RIDER, "1000"]); + shard.assert_call("debug_grant_item", &[RIDER, REINS, "1"]); + shard +} + +fn reins(shard: &Standalone) -> Vec { + shard.query_rows("SELECT * FROM game_item_instance WHERE owner_guid = 1 AND entry = 5090054") +} + +fn use_reins(shard: &Standalone) { + let items = reins(shard); + assert_eq!(items.len(), 1); + assert!( + poll_until(POLL_TIMEOUT, || shard + .call("gw_use_item", &[&actor(RIDER), &items[0]["slot"]]) + .status + .success()), + "reusable reins must cast after the global cooldown" + ); +} + +fn mount_auras(shard: &Standalone) -> Vec { + shard.query_rows("SELECT id, target_guid, caster_guid, spell_id, effect_id, eff_kind, amount, eff_p0, eff_p1 FROM game_aura WHERE target_guid = 1 AND spell_id = 50310") +} + +fn assert_projection(shard: &Standalone, display: u32, speed_bp: u32) { + let rows = shard.query_rows( + "SELECT mount_display_id, run_speed_mult_bp FROM game_world_entity WHERE guid = 1", + ); + assert_eq!(rows.len(), 1); + assert_eq!(number::(&rows[0], "mount_display_id"), display); + assert_eq!(number::(&rows[0], "run_speed_mult_bp"), speed_bp); +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn an_untrained_mount_refusal_keeps_the_item_vitals_and_auras_unchanged() { + let shard = fixture("mount-untrained"); + let items = reins(&shard); + assert_eq!(items.len(), 1); + let vitals = shard.query_rows( + "SELECT power, money, mount_display_id, run_speed_mult_bp \ + FROM game_world_entity WHERE guid = 1", + ); + let auras = shard.query_rows("SELECT id, target_guid, caster_guid, spell_id, effect_id, eff_kind, amount, eff_p0, eff_p1 FROM game_aura WHERE target_guid = 1"); + + let refused = shard.call("gw_use_item", &[&actor(RIDER), &items[0]["slot"]]); + let text = format!( + "{}{}", + String::from_utf8_lossy(&refused.stdout), + String::from_utf8_lossy(&refused.stderr) + ); + assert!(!refused.status.success(), "{text}"); + assert!( + text.contains(lyracore_shared::item::ItemRefusal::NotRightNow.as_tag()), + "{text}" + ); + assert_eq!(reins(&shard), items); + assert_eq!( + shard.query_rows( + "SELECT power, money, mount_display_id, run_speed_mult_bp \ + FROM game_world_entity WHERE guid = 1" + ), + vitals + ); + assert_eq!( + shard.query_rows("SELECT id, target_guid, caster_guid, spell_id, effect_id, eff_kind, amount, eff_p0, eff_p1 FROM game_aura WHERE target_guid = 1"), + auras + ); +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn reusable_reins_restore_the_mount_on_rebuild_and_clear_it_on_dismount() { + let shard = fixture("mount-lifetime"); + shard.assert_call("debug_spawn_at_feet", &[RIDER, "51007", "5"]); + let trainers = shard.query_rows("SELECT guid FROM game_world_entity WHERE entry = 51007"); + assert_eq!(trainers.len(), 1); + shard.assert_call( + "gw_trainer_buy", + &[&actor(RIDER), &trainers[0]["guid"], "50132"], + ); + let riding = shard.query_rows( + "SELECT current, max_rank FROM game_player_skill \ + WHERE character_guid = 1 AND skill_line = 762", + ); + assert_eq!(riding.len(), 1); + assert_eq!(riding[0]["current"], "75"); + assert_eq!(riding[0]["max_rank"], "75"); + let items = reins(&shard); + + use_reins(&shard); + assert_eq!(mount_auras(&shard).len(), 2); + assert_projection(&shard, 1147, 16_000); + assert_eq!(reins(&shard), items); + + shard.assert_sql("DELETE FROM game_world_entity WHERE guid = 1"); + shard.assert_call("debug_spawn_player_entity", &[RIDER]); + assert_eq!(mount_auras(&shard).len(), 2); + assert_projection(&shard, 1147, 16_000); + + shard.assert_call("gw_cancel_aura", &[&actor(RIDER), MOUNT]); + assert!(mount_auras(&shard).is_empty()); + assert_projection(&shard, 0, 10_000); + + use_reins(&shard); + assert_projection(&shard, 1147, 16_000); + let previous_targets = + shard.query_rows("SELECT guid FROM game_world_entity WHERE entry = 51000"); + shard.assert_call("debug_spawn_at_feet", &[RIDER, "51000", "5"]); + let targets: Vec<_> = shard + .query_rows("SELECT guid FROM game_world_entity WHERE entry = 51000") + .into_iter() + .filter(|target| { + !previous_targets + .iter() + .any(|row| row["guid"] == target["guid"]) + }) + .collect(); + assert_eq!(targets.len(), 1); + shard.assert_call("gw_attack", &[&actor(RIDER), &targets[0]["guid"]]); + assert!(mount_auras(&shard).is_empty()); + assert_projection(&shard, 0, 10_000); + assert_eq!(reins(&shard), items); +} diff --git a/module/tests/region_assignment.rs b/module/tests/region_assignment.rs new file mode 100644 index 000000000..8171c4712 --- /dev/null +++ b/module/tests/region_assignment.rs @@ -0,0 +1,31 @@ +mod support; + +use support::Standalone; + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn an_unassigned_region_retains_its_epoch_and_refuses_stale_retries() { + let mut shard = Standalone::start("region-assignment-tombstone"); + shard.publish_module(); + shard.assert_call("claim_operator", &[]); + shard.assert_call("set_region_assignment", &["0", "2", "\"pool-b\"", "2"]); + shard.assert_call("set_region_assignment", &["0", "2", "\" \"", "3"]); + + let before = shard.query_rows("SELECT shard, epoch FROM game_region_assignment WHERE key = 2"); + assert_eq!(before.len(), 1); + assert_eq!(before[0]["shard"], ""); + assert_eq!(before[0]["epoch"], "3"); + for epoch in ["2", "3"] { + let refused = shard.call("set_region_assignment", &["0", "2", "\"pool-b\"", epoch]); + assert!(!refused.status.success()); + assert!(String::from_utf8_lossy(&refused.stderr).contains("stale epoch")); + assert_eq!( + shard.query_rows("SELECT shard, epoch FROM game_region_assignment WHERE key = 2"), + before + ); + } + shard.assert_call("set_region_assignment", &["0", "2", "\" pool-c \"", "4"]); + let after = shard.query_rows("SELECT shard, epoch FROM game_region_assignment WHERE key = 2"); + assert_eq!(after[0]["shard"], "pool-c"); + assert_eq!(after[0]["epoch"], "4"); +} From 739da3e3a3e3594e899ecf1447c440c82ee5fa5b Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 14:36:45 +0200 Subject: [PATCH 10/21] fix(ci): fail closed when scanning Module print macros Scan existing Package source directories alongside the required Module source root. Accept only grep's no-match status so violations and read errors both fail the gate. Co-Authored-By: GPT-6.1 Sol via Codex --- .github/workflows/rust.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 89c6ba859..9dfafc4ec 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -25,7 +25,14 @@ jobs: # The SpacetimeDB build aborts on a print-family macro anywhere in Module source, even in a # comment or a test. Use `writeln!(std::io::stderr(), ...)` for a test diagnostic. - name: Check for print macros in Module source - run: "! grep -rnE --include='*.rs' 'e?print(ln)?!' module/src packages/*/src" + run: | + roots=(module/src) + for source in packages/*/src; do + [ ! -d "$source" ] || roots+=("$source") + done + status=0 + grep -rnE --include='*.rs' 'e?print(ln)?!' "${roots[@]}" || status=$? + [ "$status" -eq 1 ] clippy: runs-on: ubuntu-latest From fcf372136166b9240ec3725a89bed1d825f6a3af Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 14:46:00 +0200 Subject: [PATCH 11/21] test(module): cover normal world-entry mount and GM projections Exercise the Gateway login and world-port reducers alongside the debug rebuild. Verify durable GM carry fields reach the rebuilt Entity. Co-Authored-By: GPT-6.1 Sol via Codex --- module/src/world.rs | 7 ++----- module/tests/mount.rs | 40 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/module/src/world.rs b/module/src/world.rs index c7a8f5fed..84b5fd7e2 100644 --- a/module/src/world.rs +++ b/module/src/world.rs @@ -956,11 +956,8 @@ pub(crate) enum WorldEntry { WorldPort, } -/// The login core, actor-explicit (stage 4d): everything the old sender-path `player_login` -/// did after resolving WHOSE login this is. `owner` is the identity stamped onto the live entity -/// and the character's owner-RLS rows — on the gateway path (`gw::gw_player_login`, the only -/// remaining caller) the account's BOUND identity, so the rows a per-player connection would -/// see stay owned by the identity that connection would present. +/// Enter the World as the caller-authorized Actor using the Account's bound identity. +/// Live Entity and owner-addressed durable rows share the identity the player connection presents. pub(crate) fn apply_player_login( ctx: &ReducerContext, account: &crate::Account, diff --git a/module/tests/mount.rs b/module/tests/mount.rs index c2b76a6a2..2ab15f73d 100644 --- a/module/tests/mount.rs +++ b/module/tests/mount.rs @@ -19,8 +19,17 @@ fn fixture(name: &str) -> Standalone { shard } +fn bind_session(shard: &Standalone) { + let key = serde_json::to_string(&vec![7u8; 40]).unwrap(); + shard.assert_call( + "establish_session", + &[RIDER, &key, r#"{"__identity__":"0x1"}"#], + ); + shard.assert_call("gw_heartbeat", &[]); +} + fn reins(shard: &Standalone) -> Vec { - shard.query_rows("SELECT * FROM game_item_instance WHERE owner_guid = 1 AND entry = 5090054") + shard.query_rows("SELECT guid, entry, owner_guid, slot, stack_count, durability, soulbound FROM game_item_instance WHERE owner_guid = 1 AND entry = 5090054") } fn use_reins(shard: &Standalone) { @@ -115,6 +124,14 @@ fn reusable_reins_restore_the_mount_on_rebuild_and_clear_it_on_dismount() { assert_eq!(mount_auras(&shard).len(), 2); assert_projection(&shard, 1147, 16_000); + bind_session(&shard); + for entry in ["gw_player_world_port", "gw_player_login"] { + shard.assert_call(entry, &[RIDER, &actor(RIDER)]); + assert_eq!(mount_auras(&shard).len(), 2); + assert_projection(&shard, 1147, 16_000); + assert_eq!(reins(&shard), items); + } + shard.assert_call("gw_cancel_aura", &[&actor(RIDER), MOUNT]); assert!(mount_auras(&shard).is_empty()); assert_projection(&shard, 0, 10_000); @@ -139,3 +156,24 @@ fn reusable_reins_restore_the_mount_on_rebuild_and_clear_it_on_dismount() { assert_projection(&shard, 0, 10_000); assert_eq!(reins(&shard), items); } + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn world_entry_preserves_godmode_and_the_unmounted_speed_multiplier() { + let shard = fixture("gm-world-entry"); + bind_session(&shard); + shard.assert_sql( + "UPDATE game_world_entity SET godmode = true, run_speed_mult_bp = 15000 WHERE guid = 1", + ); + for entry in ["gw_player_world_port", "gw_player_login"] { + shard.assert_call(entry, &[RIDER, &actor(RIDER)]); + assert_projection(&shard, 0, 15_000); + let live = shard.query_rows("SELECT godmode FROM game_world_entity WHERE guid = 1"); + assert_eq!(live[0]["godmode"], "true"); + let durable = shard.query_rows( + "SELECT pending_godmode, pending_run_speed_mult_bp FROM game_character WHERE guid = 1", + ); + assert_eq!(durable[0]["pending_godmode"], "true"); + assert_eq!(durable[0]["pending_run_speed_mult_bp"], "15000"); + } +} From 8f775a0910b47450d55f90f1d9860d71efb14e8b Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 14:54:01 +0200 Subject: [PATCH 12/21] fix(gateway): retain Coordinator subscriptions during comment cleanup Restore the unchanged Transfer Escrow, arrival fence, Instance and addressed-event subscriptions removed while shortening adjacent documentation. Co-Authored-By: GPT-6.1 Sol via Codex --- gateway/src/stdb/connection.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/gateway/src/stdb/connection.rs b/gateway/src/stdb/connection.rs index 2ee265bde..37e7a4e9c 100644 --- a/gateway/src/stdb/connection.rs +++ b/gateway/src/stdb/connection.rs @@ -1073,6 +1073,16 @@ fn coordinator_queries(sharded_tables: bool) -> Vec<&'static str> { // RECOVERY: a restarted gateway re-derives an in-flight transfer's id, destination and // payload from this row alone. // + // Escrows, arrival fences and source instance leases are base subscriptions on every + // topology. Private Transfer state is read with the Owner Token. + "SELECT * FROM game_transfer_out", + "SELECT * FROM game_transfer_in", + "SELECT * FROM game_instance", + // Stable Coordinator relays keep addressed events available across player subscription + // changes. Callbacks route them through the World View's owner and identity indexes. + "SELECT * FROM game_teleport_event", + "SELECT * FROM game_xp_event", + "SELECT * FROM game_levelup_event", // Session-less Group Intents run on every topology, including a single database. "SELECT * FROM game_bot_invite_intent", "SELECT * FROM game_party_command_intent WHERE pending = true", From 620883a66b8af869a6aa76b46960d82b93c65a81 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 14:55:15 +0200 Subject: [PATCH 13/21] docs(test): clarify current Transfer assertion diagnostics Co-Authored-By: GPT-6.1 Sol via Codex --- module/src/transfer/harness.rs | 3 +-- module/src/transfer/tests.rs | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/module/src/transfer/harness.rs b/module/src/transfer/harness.rs index 6d335665b..6334e22fb 100644 --- a/module/src/transfer/harness.rs +++ b/module/src/transfer/harness.rs @@ -1293,8 +1293,7 @@ fn the_export_loop_hands_each_mover_the_transferring_guid() { ); assert!( decoded::(&nobody, "harness_gear").is_empty(), - "guid 0 owns nothing, so it must export nothing — `export_rows` handing every mover a \ - the reaper must use the escrow's actual Character guid" + "the reaper must use the Escrow's Character guid; guid 0 owns no rows" ); assert!( !mine.iter().any(|t| t.table == "game_transfer_out"), diff --git a/module/src/transfer/tests.rs b/module/src/transfer/tests.rs index 9fdfc84d6..676c0ba3d 100644 --- a/module/src/transfer/tests.rs +++ b/module/src/transfer/tests.rs @@ -1410,8 +1410,8 @@ fn party_membership_does_not_ride_the_export_blob() { ); assert!( crate::CHARACTER_OWNED_NOT_TRANSPORTED.contains(&"game_group_member"), - "`game_group_member` transports again. Party membership is authoritative on realm-core \ - the gateway re-pushes the roster onto the destination at world entry, and a \ + "`game_group_member` transports again. Party membership is authoritative on Realm-core; \ + the Gateway re-pushes the roster onto the destination at world entry, and a \ blob snapshot taken back at `begin_transfer` would overwrite it with the membership the \ character had when it stepped into the portal." ); From d1619bc59a432df7820fe29bd264a0930ce04041 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 14:56:19 +0200 Subject: [PATCH 14/21] docs: move the remaining binding parity module guide Describe the current schema comparison in a lookup document and keep its test header short. Co-Authored-By: GPT-6.1 Sol via Codex --- docs/binding-schema-parity.md | 30 ++++++++++++ gateway/tests/schema_parity.rs | 84 +--------------------------------- 2 files changed, 32 insertions(+), 82 deletions(-) create mode 100644 docs/binding-schema-parity.md diff --git a/docs/binding-schema-parity.md b/docs/binding-schema-parity.md new file mode 100644 index 000000000..0bec8b144 --- /dev/null +++ b/docs/binding-schema-parity.md @@ -0,0 +1,30 @@ +# Binding schema parity + +`gateway/tests/schema_parity.rs` checks the ordered names and types of every table in the +Coordinator subscription list. A Module column change without matching generated bindings +breaks BSATN row decoding. Regenerate bindings according to [danger zones](danger-zones.md). + +The test builds native Module table types with `RawModuleDefV9Builder`, then resolves nested type +references with `Typespace::inline_typerefs_in_type`. This produces the same ordered product shape +used by schema extraction, without a running database. + +Generated binding structs derive serialization and deserialization, but do not implement +`SpacetimeType`. The test constructs a real binding instance using its `Sentinel` trait and reads +two independent signals: + +1. The derived `Debug` representation supplies field names in declaration order. A parser tracks + brace, parenthesis and bracket depth so nested values cannot supply a top-level field name. +2. Field reads by name supply each real field type. Primitive and container fields use + `SpacetimeType::make_type`; generated products read their fields, and generated sums check every + variant and its BSATN tag. Module and SDK identity and timestamp types share the same pinned + SpacetimeDB types. + +The test compares field count, name at each index, and type at each index. Each manifest entry +names the Module type and the generated binding fields through `binding_shape!`. + +Only Coordinator subscriptions are included. The test reads `stdb/connection.rs` to check manifest +completeness. Per-player subscriptions and reducer arguments are outside its scope. + +The native Module dependency links on Linux, where the ELF linker removes unused Wasm host +intrinsics. The Gateway has no library target, so the test includes the self-contained generated +bindings tree through `#[path]` and compiles it into its own test binary. diff --git a/gateway/tests/schema_parity.rs b/gateway/tests/schema_parity.rs index ad689dd07..f04943d74 100644 --- a/gateway/tests/schema_parity.rs +++ b/gateway/tests/schema_parity.rs @@ -1,86 +1,6 @@ -//! Binding schema-parity test. -//! -//! `gateway/src/stdb/bindings/*_type.rs` is generated under `docs/danger-zones.md`. A -//! Module column add/reorder/retype on a -//! gateway-SUBSCRIBED table that isn't mirrored in the binding breaks live BSATN row decode -//! SILENTLY — mock-store tests cannot catch it (a real `respec_count` binding drifted silently and -//! was only found by accident, during unrelated work). This test makes that drift a RED TEST instead. -//! -//! -//! Linux-only, same reason as the armor mirror tests: it needs the `lyracore-module` dev-dep, -//! whose native build only links where the ELF linker dead-strips the SpacetimeDB wasm-host -//! intrinsics (see gateway/Cargo.toml). Schema drift is platform-independent; one platform guards it. +//! Compare the Module schema with generated bindings for every Coordinator subscription. +//! See `docs/binding-schema-parity.md` for the comparison and its platform scope. #![cfg(target_os = "linux")] -//! # How the comparison works -//! -//! Every module `#[table]` struct implements `spacetimedb_sats::SpacetimeType` (the -//! `#[table(...)]` macro's `__TableHelper` derive expands to the same thing -//! `#[derive(SpacetimeType)]` would) — `make_type(&mut impl TypespaceBuilder) -> AlgebraicType` -//! gives its REAL, ordered field-name+type shape, natively, no live node. We call it through -//! `RawModuleDefV9Builder` — the same `TypespaceBuilder` the real `spacetime` CLI's schema -//! extraction uses — then `Typespace::inline_typerefs_in_type` to resolve any nested named-type -//! refs, producing a flat, `PartialEq`-comparable `AlgebraicType::Product`. -//! -//! # Premise correction — read before editing this file -//! -//! This test's original design assumed the gateway BINDING struct also implements `SpacetimeType` -//! symmetrically ("every gateway binding struct derives/implements `SpacetimeType`"). That is -//! **false** as of the SpacetimeDB 2.7.1 codegen actually vendored here: -//! `spacetimedb-bindings-macro`'s `Serialize`, `Deserialize`, and `SpacetimeType` derives are -//! three *separate* proc-macros (`derive_serialize` / `derive_deserialize` / `schema_type` in -//! `spacetimedb-bindings-macro-2.7.1/src/lib.rs`), and every generated `*_type.rs` file derives -//! only `Serialize, Deserialize, Clone, PartialEq, Debug` — never `SpacetimeType`. So -//! `check::("game_x")` with both sides bound by `SpacetimeType` does not -//! compile. Deriving `SpacetimeType` ourselves on the generated file is a non-starter (it would -//! collide with the file's own `Serialize`/`Deserialize` impls — E0119), and hand-editing a "DO -//! NOT EDIT, AUTOMATICALLY GENERATED" file is exactly the hazard `docs/danger-zones.md` §2 warns -//! about (the next `spacetime generate` silently drops the edit). -//! -//! The fallback actually implemented here is stronger than field-count-only (the originally -//! own escape hatch: "get SOMETHING structural working; do not water down to field-count-only"). -//! For each binding struct we build ONE real instance (fields filled via the local `Sentinel` -//! trait below — real values of the REAL field types, not placeholders) and derive its schema -//! from that instance two independent ways, using only what the binding struct already derives: -//! -//! 1. **Order + names**: a struct's derived `Debug` impl visits fields in true declaration -//! order (generated from the exact same parsed field list the `Serialize` derive would use). -//! `top_level_debug_fields` parses the top-level `name: value` pairs out of -//! `format!("{inst:?}")` — brace/paren/bracket-depth aware, so a nested `Timestamp`'s own -//! derived `Debug` output (`Timestamp { __timestamp_micros_since_unix_epoch: .. }`) can't be -//! mistaken for a top-level field — and asserts it equals this file's believed field-name -//! order. Reordering/renaming/adding/removing a real field changes the true `Debug` order and -//! fails this assertion first. -//! 2. **Types**: each field is read back off the real instance BY NAME (`&inst.`, which -//! only compiles against the struct's REAL current field). Primitive and container fields -//! delegate to their own `SpacetimeType::make_type`. Generated nested command products read -//! their real fields the same way. Generated command sums use exhaustive variant matches and -//! assert each variant's actual BSATN tag. (`Identity`/`Timestamp` need no special-case -//! mapping: the gateway's `spacetimedb_sdk::Identity`/`Timestamp` and the module's -//! `spacetimedb::Identity`/`Timestamp` are the literal same `spacetimedb_lib` type — both -//! crates unify on `spacetimedb-lib`/`spacetimedb-sats` 2.7.1 in this workspace's one -//! `Cargo.lock` — so their `AlgebraicType`s compare equal directly.) -//! -//! Both signals are compared against the module's real, auto-derived `AlgebraicType::Product` for -//! field COUNT, NAME-per-index, and TYPE-per-index. The literal `check::(name)` -//! signature originally sketched isn't achievable (no `SpacetimeType` on `BindingT`); -//! `check::(name, binding_shape!(BindingT { field, field, .. }))` is the closest faithful -//! equivalent — one manifest line per table, naming both types, still driven by the REAL types. -//! -//! # Scope -//! -//! SUBSCRIBED tables only, per the coordinator's own subscription list in `stdb/connection.rs` -//! (parsed at test time below via `include_str!` — the completeness guard). Per-player -//! subscriptions set up elsewhere (`stdb/subscriptions.rs`) and reducer-arg parity are out of -//! scope. -//! -//! # No `[lib]` target workaround -//! -//! `lyracore-gateway` is a binary-only crate (no `[lib]` in `Cargo.toml`), so this -//! integration test cannot `use spacetime_core_gateway::...` (nothing to link against). The -//! generated `gateway/src/stdb/bindings/mod.rs` tree is fully self-contained (only depends on the -//! `spacetimedb_sdk` crate — verified: zero `crate::`/`super::super` references anywhere under -//! `stdb/bindings/`), so it's pulled in directly via `#[path]` below and recompiled as part of -//! this test binary instead. This changes zero gateway `src/` files. #[path = "../src/stdb/bindings/mod.rs"] #[allow(dead_code)] From 7951aed9dfdaf21b3f7ebd8fc10414eb6d5e9fdc Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 15:22:46 +0200 Subject: [PATCH 15/21] docs: point Rust comments at moved docs and drop dangling ones Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/lyracore-shared/src/vmap.rs | 3 +-- docs/module-build.md | 2 +- gateway/tests/schema_parity.rs | 2 +- importer/src/main.rs | 3 +-- importer/src/vmap.rs | 2 +- module/src/nav.rs | 2 +- module/src/seed.rs | 2 +- module/src/seed/fixtures.rs | 2 +- module/src/spell/stacking.rs | 2 +- 9 files changed, 9 insertions(+), 11 deletions(-) diff --git a/crates/lyracore-shared/src/vmap.rs b/crates/lyracore-shared/src/vmap.rs index 63a7a750f..cd9c5b145 100644 --- a/crates/lyracore-shared/src/vmap.rs +++ b/crates/lyracore-shared/src/vmap.rs @@ -1,6 +1,5 @@ //! Per-cell collision triangles and exact segment-triangle ray queries. -//! See `docs/decisions.md` section 10 for why line of sight uses WMO triangles while collision -//! also uses M2 doodads. A versioned blob carries source metadata for both ray flavors. +//! Line of sight uses WMO triangles; collision also uses M2 doodads. A versioned blob carries source metadata for both ray flavors. //! Queries walk only the terrain cells the segment crosses with a two-dimensional DDA. /// Blob format version. Bump on any layout change; `decode` rejects a mismatch rather than guess. diff --git a/docs/module-build.md b/docs/module-build.md index a5422357f..4629a30ea 100644 --- a/docs/module-build.md +++ b/docs/module-build.md @@ -2,7 +2,7 @@ `module/build.rs` discovers Package modules and generates registration arrays in `$OUT_DIR`. It scans Rust files in `module/src/` and each installed `packages//src/` directory. -`packages/example/` is the maintained reference Package and does not enable `has_packages`. +Any installed Package enables the `has_packages` cfg. Core ships no enabled Packages. A Package supplies `src/mod.rs` and any sibling modules. Discovery generates `package_mods.rs` with a `pkg_` module for each Package, so installing one requires no Core source edit. diff --git a/gateway/tests/schema_parity.rs b/gateway/tests/schema_parity.rs index f04943d74..d67e4cb4e 100644 --- a/gateway/tests/schema_parity.rs +++ b/gateway/tests/schema_parity.rs @@ -1313,7 +1313,7 @@ fn every_subscribed_table_in_connection_rs_has_a_parity_manifest_entry() { "connection.rs subscribes {missing:?} but gateway/tests/schema_parity.rs has no \ `parity_test!` line for it — add one (copy the pattern from any existing entry above) \ AND add the table name to MANIFEST_TABLES, or a gateway binding drift on this table will \ - break live BSATN decode silently. See docs/agent-playbook.md failure-mode §1." + break live BSATN decode silently." ); } diff --git a/importer/src/main.rs b/importer/src/main.rs index 9fa4a57d9..7fb7580e5 100644 --- a/importer/src/main.rs +++ b/importer/src/main.rs @@ -29,8 +29,7 @@ //! --nav WMO/M2 nav-grid rasterizer (nav.rs) //! --vmap exact bounded collision-triangle extract + pack + import //! (--apply loads `game_vmap_chunk` via import_vmap_chunks; a -//! dry run stops at report, vmap.rs, -//! docs/decisions.md §10) +//! dry run stops at report, vmap.rs) //! --vmap-prepare-coverage derive path-grid coverage for an already-staged vmap //! generation (--apply calls prepare_/finalize_vmap_nav_coverage, //! resumable; a dry run reports the plan) — vmap.rs::run_coverage diff --git a/importer/src/vmap.rs b/importer/src/vmap.rs index 7032f6cab..9b9f2ffd0 100644 --- a/importer/src/vmap.rs +++ b/importer/src/vmap.rs @@ -1,7 +1,7 @@ //! Client geometry extraction for `--vmap` and `--vmap-prepare-coverage`. //! Tile scans reuse nav mesh loading and placement calibration. Collision triangles use full //! rotation and the shared per-cell codec. `--apply` sends bounded batches to the import reducers. -//! Client bytes stay in memory. See `docs/decisions.md` section 10 for the geometry policy. +//! Client bytes stay in memory. use anyhow::{bail, Context, Result}; use lyracore_shared::terrain::cell_key; diff --git a/module/src/nav.rs b/module/src/nav.rs index 0a9f67313..d0a5205c1 100644 --- a/module/src/nav.rs +++ b/module/src/nav.rs @@ -1,4 +1,4 @@ -//! Nav grid: walkability + line-of-sight obstruction data See `docs/decisions.md` section 8. +//! Nav grid: walkability and line-of-sight obstruction data. //! //! One row per terrain MCNK cell that has ANY obstruction, steep slope, or hole — fully-clear //! cells emit no row, so a missing chunk means "no obstacles known here" and every reader keeps diff --git a/module/src/seed.rs b/module/src/seed.rs index 8dafcd2c2..bcfdb0981 100644 --- a/module/src/seed.rs +++ b/module/src/seed.rs @@ -2034,7 +2034,7 @@ mod tests { /// The live probe's premise: its two stamina buffs share one magnitude-compared family and its /// two Blessings share the per-caster family. A membership or rule edit that breaks either pairing - /// would leave `docs/aura-stacking-probes.md` describing an outcome the module no longer produces. + /// would leave `docs/verification/aura-stacking-probes.md` describing an outcome the module no longer produces. #[test] fn the_live_probe_fixture_spells_sit_in_the_families_the_probe_documents() { let family_of = |spell_id: u32| { diff --git a/module/src/seed/fixtures.rs b/module/src/seed/fixtures.rs index 8aa3b8879..e8075457e 100644 --- a/module/src/seed/fixtures.rs +++ b/module/src/seed/fixtures.rs @@ -1747,7 +1747,7 @@ fn upsert_effect(ctx: &spacetimedb::ReducerContext, row: SpellEffect) { } /// Stacking-family probe fixture — the four real family members a live aura-stacking probe needs -/// (`docs/aura-stacking-probes.md`). +/// (`docs/verification/aura-stacking-probes.md`). /// /// A curated sandbox carries only rank 1 of each aura family and every one of those is self-cast, /// so neither "the stronger member wins from either caster" nor "two paladins, one target" can be diff --git a/module/src/spell/stacking.rs b/module/src/spell/stacking.rs index 1d5e9bb4f..cb32233b7 100644 --- a/module/src/spell/stacking.rs +++ b/module/src/spell/stacking.rs @@ -896,7 +896,7 @@ mod tests { } /// One player target's `(category)` DR state driven through the PRODUCTION policy fns — the - /// in-process twin of the live probe in `docs/cc-diminishing-returns.md`, so the ladder, the window + /// in-process twin of the live probe in `docs/verification/cc-diminishing-returns-probe.md`, so the ladder, the window /// stamping and the level agreement are asserted without a `ReducerContext`. struct DrProbe { row: Option, From 738fde47d8f70f898d1ecd10f7f86e2e55d6b551 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 15:34:40 +0200 Subject: [PATCH 16/21] docs: normalize spacing artifacts in swept Rust comments Co-Authored-By: Claude Sonnet 5.5 --- crates/lyracore-shared/src/constants.rs | 2 +- crates/lyracore-shared/src/loot_roll.rs | 4 +- crates/lyracore-shared/src/quest.rs | 2 +- crates/lyracore-shared/src/trade.rs | 6 +- gateway/src/codec/addon.rs | 2 +- gateway/src/codec/movement.rs | 2 +- gateway/src/codec/update_mask.rs | 4 +- gateway/src/codec/values.rs | 4 +- gateway/src/stdb/armor.rs | 4 +- gateway/src/stdb/connection.rs | 4 +- gateway/src/stdb/subscriptions.rs | 12 ++-- gateway/src/stdb/world_view.rs | 4 +- gateway/src/world/handlers/query.rs | 2 +- gateway/src/world/handlers/trade.rs | 2 +- gateway/src/world/loot.rs | 2 +- gateway/src/world/tests.rs | 2 +- gateway/src/world/trade_tests.rs | 10 ++-- importer/src/dbc.rs | 12 ++-- importer/src/main.rs | 78 ++++++++++++------------- importer/src/nav.rs | 4 +- importer/src/spell.rs | 8 +-- module/src/action_bar.rs | 2 +- module/src/auth.rs | 2 +- module/src/character.rs | 2 +- module/src/combat/death.rs | 6 +- module/src/combat/swing.rs | 2 +- module/src/config.rs | 2 +- module/src/corpse.rs | 2 +- module/src/creatures/ai.rs | 4 +- module/src/creatures/spawn.rs | 8 +-- module/src/creatures/tick/mod.rs | 10 ++-- module/src/debug/instance.rs | 2 +- module/src/debug/mod.rs | 6 +- module/src/encounter.rs | 2 +- module/src/gameobject.rs | 16 ++--- module/src/gc.rs | 4 +- module/src/gm.rs | 2 +- module/src/graveyard.rs | 4 +- module/src/group.rs | 2 +- module/src/helpers.rs | 4 +- module/src/items/ops.rs | 4 +- module/src/lib.rs | 8 +-- module/src/loot/mod.rs | 16 ++--- module/src/motion.rs | 2 +- module/src/professions.rs | 4 +- module/src/quest.rs | 18 +++--- module/src/seed.rs | 10 ++-- module/src/seed/fixtures.rs | 2 +- module/src/skill.rs | 8 +-- module/src/spell/cast/targeting.rs | 4 +- module/src/spell/control.rs | 4 +- module/src/spell/effects.rs | 2 +- module/src/spell/scheduler.rs | 4 +- module/src/spell/tables.rs | 6 +- module/src/spell/tests.rs | 2 +- module/src/trainer.rs | 4 +- module/src/transfer/tests.rs | 4 +- module/src/transfer/transport.rs | 8 +-- module/src/tripwires.rs | 6 +- module/src/world.rs | 8 +-- module/src/xp.rs | 2 +- 61 files changed, 189 insertions(+), 189 deletions(-) diff --git a/crates/lyracore-shared/src/constants.rs b/crates/lyracore-shared/src/constants.rs index e5dec2591..7284e3e4b 100644 --- a/crates/lyracore-shared/src/constants.rs +++ b/crates/lyracore-shared/src/constants.rs @@ -342,7 +342,7 @@ pub mod taxi_fixture { (0xF130_u64 << 48) | ((FLIGHT_MASTER_ENTRY as u64) << 24) | 1; } -/// Gossip menu OPTION : `game_gossip_option.action` codes as they land verbatim from +/// Gossip menu OPTION: `game_gossip_option.action` codes as they land verbatim from /// the cmangos dump's `gossip_menu_option.OptionType`/`option_id` column (the importer copies it /// through unchanged — this module documents what the values MEAN so the gateway dispatch and the /// importer agree without duplicating magic numbers). `[V]` — confirm against your own dump; only diff --git a/crates/lyracore-shared/src/loot_roll.rs b/crates/lyracore-shared/src/loot_roll.rs index d7acc2f47..3097fd447 100644 --- a/crates/lyracore-shared/src/loot_roll.rs +++ b/crates/lyracore-shared/src/loot_roll.rs @@ -30,7 +30,7 @@ pub mod event_kind { pub const MONEY_SHARE: u8 = 8; } -/// The REALM-CORE loot-roll ops : the `op` byte of the operator-gated `realm_loot_op` +/// The REALM-CORE loot-roll ops: the `op` byte of the operator-gated `realm_loot_op` /// reducer, mirroring `crate::group::realm_op`'s one-reducer-not-several trade (see that module's /// doc for why: a hand-maintained gateway binding per reducer, `docs/danger-zones.md` §1.2). /// @@ -322,7 +322,7 @@ mod tests { assert!(decode_money_share("-1").is_none()); // u32, no sign } - // ---- Realm-core loot-roll ops ---- + // ---- Realm-core loot-roll ops ---- /// The op byte is a WIRE value the gateway sends and the module dispatches on, deployed /// separately — a renumber only one side learns about silently runs the wrong op (a VOTE diff --git a/crates/lyracore-shared/src/quest.rs b/crates/lyracore-shared/src/quest.rs index 69b20fd92..28ad6647b 100644 --- a/crates/lyracore-shared/src/quest.rs +++ b/crates/lyracore-shared/src/quest.rs @@ -233,7 +233,7 @@ mod tests { assert_eq!(max_level_money_reward(u32::MAX), u32::MAX); // saturates, never wraps/panics } - // ---- Sharing : share_event_kind distinctness + share_result priority ---- + // ---- Sharing: share_event_kind distinctness + share_result priority ---- /// `QUEST_SHARE`/`QUEST_PUSH_RESULT` (10/11) follow every loot-roll kind (4..=8) and skip kind /// 9, retired party chat, which nothing may reuse. diff --git a/crates/lyracore-shared/src/trade.rs b/crates/lyracore-shared/src/trade.rs index 003420647..7c6d31251 100644 --- a/crates/lyracore-shared/src/trade.rs +++ b/crates/lyracore-shared/src/trade.rs @@ -39,12 +39,12 @@ pub mod event_kind { /// Initiator notice: the target has you ignored — proposal declined → /// `TradeStatus::IgnoreYou`. pub const IGNORE_YOU: u8 = 11; - /// Your partner accepted the current offer → `TradeStatus::TradeAccept`. + /// Your partner accepted the current offer → `TradeStatus::TradeAccept`. pub const TRADE_ACCEPT: u8 = 12; /// An accept was withdrawn — explicit unaccept, or the accept-reset rule firing on an offer - /// change → `TradeStatus::BackToTrade`. + /// change → `TradeStatus::BackToTrade`. pub const BACK_TO_TRADE: u8 = 13; - /// The Trade Commit succeeded → `TradeStatus::TradeComplete`. + /// The Trade Commit succeeded → `TradeStatus::TradeComplete`. pub const TRADE_COMPLETE: u8 = 14; /// Commit refused, window closes: YOUR bags cannot fit the incoming items → /// `TradeStatus::CloseWindow` with `InventoryResult::InventoryFull`, `target_error: false`. diff --git a/gateway/src/codec/addon.rs b/gateway/src/codec/addon.rs index 857040fa2..6624356ff 100644 --- a/gateway/src/codec/addon.rs +++ b/gateway/src/codec/addon.rs @@ -256,7 +256,7 @@ mod tests { } } - /// a SAY addon frame is not a typed chat type, so the caller keeps dropping it , + /// a SAY addon frame is not a typed chat type, so the caller keeps dropping it, /// SAY has no chat-kind route to a real player, addon-language or not. #[test] fn a_say_addon_frame_is_still_dropped() { diff --git a/gateway/src/codec/movement.rs b/gateway/src/codec/movement.rs index b885a45d9..290be3888 100644 --- a/gateway/src/codec/movement.rs +++ b/gateway/src/codec/movement.rs @@ -370,7 +370,7 @@ pub fn build_taxi_move_raw( Some((0x00DD, body)) } -/// Build `SMSG_MONSTER_MOVE` as a FACING-ONLY packet : `mover_guid` does not move, `pos` is +/// Build `SMSG_MONSTER_MOVE` as a FACING-ONLY packet: `mover_guid` does not move, `pos` is /// both the spline point and its sole (degenerate) destination, `duration` 0 — but its heading /// changes to `angle_rad`. This is the wire tool the issue calls out as "already exists, unwired": /// gtker 0.3 models 1.12's `FacingAngle` `MonsterMoveType` (`spline_point`/`splines` framing diff --git a/gateway/src/codec/update_mask.rs b/gateway/src/codec/update_mask.rs index f8f9080c3..4a1d341a0 100644 --- a/gateway/src/codec/update_mask.rs +++ b/gateway/src/codec/update_mask.rs @@ -33,7 +33,7 @@ use std::collections::BTreeMap; /// Vanilla (build 5875) UNIT/PLAYER object update-field indices, each a u32 slot. Every number is -/// the literal `set_int(N,..)` / `set_bytes(N,..)` argument from `wow_world_messages`' setters, so +/// the literal `set_int(N, ..)` / `set_bytes(N, ..)` argument from `wow_world_messages`' setters, so /// the byte-equivalence tests cross-check them against gtker's serializer. /// /// This is the reference layout for the whole vanilla descriptor, not just the fields a caller @@ -97,7 +97,7 @@ pub mod idx { /// Slot N (0-indexed): `CONTAINER_FIELD_SLOT_1 + N * 2`. The gtker vanilla builder only exposes /// `set_container_slot_1` (this index); slots 1+ require the hand-rolled raw encoder (same /// 'gtker descriptor-setter wall' as multi-aura). Cross-checked against `wow_world_messages` - /// vanilla `UpdateContainerBuilder::set_container_slot_1 → set_guid(50,..)`. + /// vanilla `UpdateContainerBuilder::set_container_slot_1 → set_guid(50, ..)`. pub const CONTAINER_FIELD_SLOT_1: u16 = 50; /// `PLAYER_DUEL_ARBITER` — the duel flag gameobject guid, u64 at slots 188 and 189. diff --git a/gateway/src/codec/values.rs b/gateway/src/codec/values.rs index 53392001c..2f3ac2ec5 100644 --- a/gateway/src/codec/values.rs +++ b/gateway/src/codec/values.rs @@ -325,7 +325,7 @@ pub fn build_armor_values(guid: u64, total: [u32; 7], pos_buff: u32) -> SMSG_UPD /// module already folded — split into the green/red paperdoll halves below via plain sign arithmetic, /// not a second aura read. `attack_power` is the stat-derived base AP; `ap_mods` is the `A_MOD_COMBAT(ATTACK_POWER)` /// aura portion alone (Battle Shout) — vanilla renders those through two different wire fields. -/// `crit_pct` is `module::combat::effective_crit_bp`/100.0, the SAME basis-point value the +/// `crit_pct` is `module::combat::effective_crit_bp`/100.0, the SAME basis-point value the /// swing table rolls against, converted to the float percent `PLAYER_CRIT_PERCENTAGE` wants; no /// second crit formula lives on the gateway. pub struct SheetStatsValues { @@ -376,7 +376,7 @@ pub fn build_sheet_stats_values(guid: u64, s: &SheetStatsValues) -> SMSG_UPDATE_ p.set_player_field_negstat4(s.spi_bonus.min(0)); p.set_unit_attack_power(s.attack_power as i32); // UNIT_FIELD_ATTACK_POWER_MODS packs two UNSIGNED shorts (pos, neg-as-magnitude), mirroring - // mangos's `SetInt16Value(field, 0/1,..)`, never a signed short (a negative AP debuff isn't + // mangos's `SetInt16Value(field, 0/1, ..)`, never a signed short (a negative AP debuff isn't // wired yet; `ap_mods` is currently always ≥0 from Battle Shout, so `neg` is 0 in practice). p.set_unit_attack_power_mods(s.ap_mods.max(0) as u16, (-s.ap_mods).max(0) as u16); p.set_unit_mindamage(s.dmg_min as f32); diff --git a/gateway/src/stdb/armor.rs b/gateway/src/stdb/armor.rs index 545984d07..0a40bd44e 100644 --- a/gateway/src/stdb/armor.rs +++ b/gateway/src/stdb/armor.rs @@ -307,7 +307,7 @@ mod tests { } // The former `melee_attack_power_for`/`swing_range_ap` gateway-side mirrors (and the parity tests - // that pinned them against `lyracore_module`) are GONE : `sheet_stats` above is now a plain + // that pinned them against `lyracore_module`) are GONE: `sheet_stats` above is now a plain // read of `module::spell::recompute_sheet`'s output, so there is no gateway-side formula left to // drift from the module — the module row IS the source of truth. @@ -316,7 +316,7 @@ mod tests { // `effective_armor` itself needs a live `RemoteTables` subscription cache and is NOT // unit-tested here (no ReducerContext mocking) — this only pins that its two REAL pure // terms (`aura_armor_contribution`, `gear_armor_contribution`) compose by simple addition - // with a base, and that the max(0,..) clamp `effective_armor` applies to that same sum + // with a base, and that the max(0, ..) clamp `effective_armor` applies to that same sum // never underflows on a debuff exceeding base + gear. let base = 40i64; // agility 20 * 2 let aura = aura_armor_contribution(0xA1, 0x01, 160, 1) as i64; // Demon Skin diff --git a/gateway/src/stdb/connection.rs b/gateway/src/stdb/connection.rs index 37e7a4e9c..b47ec1fd4 100644 --- a/gateway/src/stdb/connection.rs +++ b/gateway/src/stdb/connection.rs @@ -1490,7 +1490,7 @@ fn spawn_load_sampler( ) -> std::thread::JoinHandle<()> { let sampler = crate::load_sample::OccupancySampler::from_env(&stdb_uri); let interval = crate::load_sample::sample_interval(); - // Hashed once at startup : `game_shard_load.gateway_key` is what keys this + // Hashed once at startup: `game_shard_load.gateway_key` is what keys this // process's samples apart from every OTHER gateway process's, so N gateways sampling the same // shard sum to a realm-wide total instead of the last writer clobbering the rest. let this_gateway_key = crate::load_sample::gateway_key(&gateway_id); @@ -2723,7 +2723,7 @@ impl Coordinator { } })); } - // The cross-shard group and chat twins ride realm-core's connection, armed only + // The cross-shard group and chat twins ride realm-core's connection, armed only // when realm-core is a DISTINCT database (a world shard's own `arm_shard` above already // watches these tables, and a second registration would deliver every packet twice). if let Ok(realm) = self.realm_core() { diff --git a/gateway/src/stdb/subscriptions.rs b/gateway/src/stdb/subscriptions.rs index e0e210114..aff86ba0a 100644 --- a/gateway/src/stdb/subscriptions.rs +++ b/gateway/src/stdb/subscriptions.rs @@ -1893,7 +1893,7 @@ pub(crate) fn aura_snapshot_outbound( out } -/// Trade-status relay : `game_trade_event.kind` → the `SMSG_TRADE_STATUS` variant, to the +/// Trade-status relay: `game_trade_event.kind` → the `SMSG_TRADE_STATUS` variant, to the /// row's recipient and nobody else (audience resolved by the caller, the `auction_notice_outbound` /// shape). The kind byte is `lyracore_shared::trade::event_kind` — NOT the vanilla discriminant; /// this match IS the wire mapping. An unknown kind (a newer module mid-rollout) drops with a warn @@ -1937,7 +1937,7 @@ pub(crate) fn trade_event_outbound(row: &TradeEvent) -> Vec { kind::TRADE_ACCEPT => Some(SMSG_TRADE_STATUS::TradeAccept), kind::BACK_TO_TRADE => Some(SMSG_TRADE_STATUS::BackToTrade), kind::TRADE_COMPLETE => Some(SMSG_TRADE_STATUS::TradeComplete), - // Commit refused on bag space : the window closes with the inventory error; + // Commit refused on bag space: the window closes with the inventory error; // `target_error` says WHOSE bags — false = yours, true = the partner's. kind::INV_FULL_SELF => Some(SMSG_TRADE_STATUS::CloseWindow { inventory_result: wow_world_messages::vanilla::InventoryResult::InventoryFull, @@ -2103,7 +2103,7 @@ pub(crate) fn duel_winner_outbound(row: &DuelEvent) -> Vec { ))] } -/// Decode an `OFFER_*` payload into the fixed-444-byte `SMSG_TRADE_STATUS_EXTENDED` : +/// Decode an `OFFER_*` payload into the fixed-444-byte `SMSG_TRADE_STATUS_EXTENDED`: /// counts are 7/7 (the cmangos constant), unused slots stay zeroed (`TradeSlot::default`), and /// every filled slot carries the module-resolved stack/durability/enchant fields. Fails closed /// with the payload decoder. `describes_partner` sets the wire's misnamed `self_player` byte — @@ -3233,7 +3233,7 @@ fn entity_update_to_outbound_with_dynamic_flags( out.push(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(m))); } } - // Sheath relay : UNIT_FIELD_BYTES_2 byte 0 flipping as a unit draws or stows its weapon. + // Sheath relay: UNIT_FIELD_BYTES_2 byte 0 flipping as a unit draws or stows its weapon. // Any unit, not player-gated — a creature drawing on engage is the same wire field. Without // this arm the server knows the state and no observer ever hears about it. if old.unit_bytes_2 != new.unit_bytes_2 { @@ -4904,7 +4904,7 @@ mod tests { )); } - /// The trade-status wire mapping : every `lyracore_shared::trade::event_kind` the module + /// The trade-status wire mapping: every `lyracore_shared::trade::event_kind` the module /// emits decodes to its `SMSG_TRADE_STATUS` variant — `BeginTrade` carrying the counterparty /// guid the client needs to open the window — and an unknown kind (newer module mid-rollout) /// drops rather than desyncing the trade window. @@ -5120,7 +5120,7 @@ mod tests { ); } - /// The OFFER_* kinds decode to the fixed-444-byte extended status : the polarity byte + /// The OFFER_* kinds decode to the fixed-444-byte extended status: the polarity byte /// comes from the KIND (never inferred), the window-visible item fields survive the payload /// round-trip into the right wire slots, unused slots stay zeroed, and a malformed payload /// drops the packet entirely. diff --git a/gateway/src/stdb/world_view.rs b/gateway/src/stdb/world_view.rs index e9b2b72d2..2de570cf9 100644 --- a/gateway/src/stdb/world_view.rs +++ b/gateway/src/stdb/world_view.rs @@ -1444,7 +1444,7 @@ fn guarded(what: &str, body: impl FnOnce()) { } // ----------------------------------------------------------------------------------------------- -// Registration helpers, the clone-per-callback + `guarded(label,...)` shape that every +// Registration helpers, the clone-per-callback + `guarded(label, ...)` shape that every // `arm_shard` registration repeated verbatim, factored to one line per callsite. `T::Row` and // `EventContext` are pinned to the module's generated types, so a mismatched table/body pairing // is a compile error, not a mislabeled log line. @@ -4024,7 +4024,7 @@ mod family_audience_tests { assert!(queued_job(&actor_rx).is_empty()); } - /// The same guard also has to check which Guild the membership row names: `map_or(0,...)` + /// The same guard also has to check which Guild the membership row names: `map_or(0, ...)` /// ignored the returned guild_id, so a member who moved to another Guild between this job's /// enqueue and its run would still get a roster, rendered under its new Guild's own Rank /// Rights. diff --git a/gateway/src/world/handlers/query.rs b/gateway/src/world/handlers/query.rs index eac95dba4..033706d9e 100644 --- a/gateway/src/world/handlers/query.rs +++ b/gateway/src/world/handlers/query.rs @@ -285,7 +285,7 @@ pub(crate) fn handle_query( )?; } Some(gossip_option::UNLEARNTALENTS) => { - // Respec. Errors (out of range / not enough gold) are per-action , + // Respec. Errors (out of range / not enough gold) are per-action, // the window closes either way, same as bind_home above. let _ = store.reset_talents(conn.account_id, player_guid, npc); send(tx, Outbound::One(ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE))?; diff --git a/gateway/src/world/handlers/trade.rs b/gateway/src/world/handlers/trade.rs index e2556a06e..fd2b71484 100644 --- a/gateway/src/world/handlers/trade.rs +++ b/gateway/src/world/handlers/trade.rs @@ -117,7 +117,7 @@ pub(crate) fn handle_trade( } Ok(None) } - // Proposal declines : the client auto-answers a BeginTrade it can't take, busy + // Proposal declines: the client auto-answers a BeginTrade it can't take, busy // (already in a dialog) or the initiator is on the ignore list. ClientOpcodeMessage::CMSG_BUSY_TRADE => { if let Some(me) = self_guid(conn) { diff --git a/gateway/src/world/loot.rs b/gateway/src/world/loot.rs index edcb98a51..e2647e611 100644 --- a/gateway/src/world/loot.rs +++ b/gateway/src/world/loot.rs @@ -89,7 +89,7 @@ fn promote_one(shard: &dyn WorldStore, realm: &dyn WorldStore, roll: &PendingLoo /// calls it immediately before dispatching a LEAVE/UNINVITE — the two ops that can shrink a group /// below 2 members and reach `remove_member`'s disband branch. This is what closes the disband race /// the periodic [`relay_tick`] alone cannot (see this module's doc): by the time -/// `realm_group_op(LEAVE/UNINVITE,..)` runs right after this returns, every roll that existed +/// `realm_group_op(LEAVE/UNINVITE, ..)` runs right after this returns, every roll that existed /// anywhere in the realm at that moment is already on realm-core for `remove_member` to see. /// /// Every connected world shard, not just the acting character's own — a party's members, and diff --git a/gateway/src/world/tests.rs b/gateway/src/world/tests.rs index 12a43d4ef..fb668343f 100644 --- a/gateway/src/world/tests.rs +++ b/gateway/src/world/tests.rs @@ -3246,7 +3246,7 @@ impl WorldStore for InMemoryStore { self.rec("group_accept"); Ok(PartyOutcome::Ran) } - // Trade : pure recorders, the module owns every gate, so the fake just proves which + // Trade: pure recorders, the module owns every gate, so the fake just proves which // verb the dispatch chose and which args survived the wire. fn initiate_trade(&self, _account_id: u64, self_guid: u64, target_guid: u64) -> Result<()> { self.rec("initiate_trade"); diff --git a/gateway/src/world/trade_tests.rs b/gateway/src/world/trade_tests.rs index 06b5adf6a..d5b0f3d77 100644 --- a/gateway/src/world/trade_tests.rs +++ b/gateway/src/world/trade_tests.rs @@ -72,7 +72,7 @@ fn the_handshake_flow_dispatches_initiate_then_begin_then_cancel() { assert_eq!(calls, ["initiate_trade", "begin_trade", "cancel_trade"]); } -/// **AC : offer mutations dispatch with the wire's arguments**, set item (main bag → +/// **AC: offer mutations dispatch with the wire's arguments**, set item (main bag → /// absolute slot passthrough), clear item, and gold (the `Gold` wire type decoded back to /// copper). One client, three opcodes, three recorders. #[test] @@ -110,7 +110,7 @@ fn offer_mutations_dispatch_with_wire_arguments() { ); } -/// **AC : sub-bag items are out of scope, not mis-addressed**, a `CMSG_SET_TRADE_ITEM` +/// **AC: sub-bag items are out of scope, not mis-addressed**, a `CMSG_SET_TRADE_ITEM` /// from an equipped sub-bag is logged and IGNORED (the item-action dispatcher posture), never /// forwarded /// with a bag-local slot number that would alias a main-bag slot. @@ -130,7 +130,7 @@ fn set_trade_item_from_a_sub_bag_is_ignored_not_misaddressed() { assert!(store.set_trade_items.lock().unwrap().is_empty()); } -/// **AC : the full loop's wire half**, A initiates and offers an item, B answers, offers +/// **AC: the full loop's wire half**, A initiates and offers an item, B answers, offers /// gold, and both accept; every opcode dispatches its verb as the right seat, in order. (The /// swap itself — items, gold, atomicity — is the module's pure-tested commit core; the fake /// store records the dispatch, per the settled seam.) @@ -182,7 +182,7 @@ fn the_full_loop_dispatches_offer_and_dual_accept_in_order() { assert_eq!(store.accepted_trades.lock().unwrap().as_slice(), &[1, 2]); } -/// **AC : the accept-reset wire half**, after an accept, a further offer mutation and an +/// **AC: the accept-reset wire half**, after an accept, a further offer mutation and an /// explicit unaccept both dispatch; the reset itself (both flags cleared, BackToTrade to both) /// is the module's pure-tested rule. #[test] @@ -207,7 +207,7 @@ fn unaccept_and_post_accept_mutations_dispatch_for_the_acting_seat() { assert_eq!(store.unaccepted_trades.lock().unwrap().as_slice(), &[1]); } -/// **AC : the decline flow**, the proposed target's client answers a `BeginTrade` it +/// **AC: the decline flow**, the proposed target's client answers a `BeginTrade` it /// can't take with `CMSG_BUSY_TRADE` (already in a dialog) or `CMSG_IGNORE_TRADE` (initiator /// ignored); each dispatches its own decline verb as the declining side. #[test] diff --git a/importer/src/dbc.rs b/importer/src/dbc.rs index c520d27db..1086ff37f 100644 --- a/importer/src/dbc.rs +++ b/importer/src/dbc.rs @@ -216,7 +216,7 @@ pub fn run(data_dir: &str, args: &Args) -> Result<()> { let safe_locs: DbcWorldSafeLocs = read_table(&mut chain)?; let (graveyard_stmts, graveyard_count) = graveyard_sql(&safe_locs); - // Creature families : CreatureFamily.dbc → game_creature_family. Small table (no + // Creature families: CreatureFamily.dbc → game_creature_family. Small table (no // map filtering — same "load ALL rows" convention as the other DBC-backed lookups above). No // Timestamp → plain SQL, same clear+reload shape. let creature_families: DbcCreatureFamily = read_table(&mut chain)?; @@ -227,7 +227,7 @@ pub fn run(data_dir: &str, args: &Args) -> Result<()> { .filter(|r| r.pet_talent_type != -1) .count(); - // Locks : Lock.dbc → game_lock, the DATA half of open-lock (119 wires enforcement). + // Locks: Lock.dbc → game_lock, the DATA half of open-lock (119 wires enforcement). // Small table (a few hundred rows in vanilla) — load ALL, same "load ALL rows" convention as the // other DBC-backed lookups above. No Timestamp → plain SQL, same clear+reload shape. let locks: DbcLock = read_table(&mut chain)?; @@ -780,7 +780,7 @@ fn faction_template_sql(chain: &mut PatchChain) -> Result<(Vec, usize)> Ok((stmts, n)) } -/// Clear+reload SQL for `game_area` from `AreaTable.dbc` : every zone AND subzone row +/// Clear+reload SQL for `game_area` from `AreaTable.dbc`: every zone AND subzone row /// (id, map, parent, area_bit, flags, exploration level, faction group, name). `flags` is the raw /// `AreaFlags` bitmask reinterpreted as `u32` (`as_int()` widened) — undecoded here, a consumer /// decodes what it needs (rest-state city detection is 196's job). Name via `.en_gb` + `sql_text` @@ -815,7 +815,7 @@ fn area_sql(table: &DbcAreaTable) -> (Vec, usize) { (stmts, n) } -/// Clear+reload SQL for `game_area_trigger` from `AreaTrigger.dbc` : the geometric +/// Clear+reload SQL for `game_area_trigger` from `AreaTrigger.dbc`: the geometric /// half of inn triggers (196), dungeon entrances (190), and quest explore objectives — a trigger /// volume is either a sphere (`radius`) or a box (`box_length`/`box_width`/`box_height`/`box_yaw`); /// the DBC carries both fields for every row regardless of which shape a given trigger actually @@ -852,8 +852,8 @@ fn area_trigger_sql(table: &DbcAreaTrigger) -> (Vec, usize) { (stmts, n) } -/// Clear+reload SQL for `game_graveyard` from `WorldSafeLocs.dbc` : a graveyard's -/// fixed position, replacing the hand-coded `world::graveyard::{NORTHSHIRE, GOLDSHIRE,...}` consts +/// Clear+reload SQL for `game_graveyard` from `WorldSafeLocs.dbc`: a graveyard's +/// fixed position, replacing the hand-coded `world::graveyard::{NORTHSHIRE, GOLDSHIRE, ...}` consts /// as the primary data source (those consts — and `seed.rs`'s row-seeded twins — remain the /// no-import fallback). No orientation column — the DBC carries none. Name via `.en_gb` + /// `sql_text`. Loads ALL rows (small table). No Timestamp → plain SQL. diff --git a/importer/src/main.rs b/importer/src/main.rs index 7fb7580e5..b1aa303f5 100644 --- a/importer/src/main.rs +++ b/importer/src/main.rs @@ -111,7 +111,7 @@ mod ct { pub const MIN_LOOT_GOLD: usize = 54; pub const MAX_LOOT_GOLD: usize = 55; pub const LOOT_ID: usize = 56; // → creature_loot_template.entry (0 = no loot table) - // Loot-family completeness : PickpocketLootId/SkinLootId sit IMMEDIATELY after LootId + // Loot-family completeness: PickpocketLootId/SkinLootId sit IMMEDIATELY after LootId // (LootId, PickpocketLootId, SkinningLootId are consecutive columns). These two carried a `[V]` // "confirm against your own dump" caveat until the pinned dump's DDL was enumerated directly — // both are now confirmed, along with every other const here. @@ -133,7 +133,7 @@ mod gm { pub const CONDITION_ID: usize = 3; } mod npct { - // npc_text (full 81-col format): ID, text0_0, text0_1, lang0, prob0, em0_0..5, text1_0,... + // npc_text (full 81-col format): ID, text0_0, text0_1, lang0, prob0, em0_0..5, text1_0, ... pub const ID: usize = 0; pub const NCOLS_FULL: usize = 81; // 1 + 8*(2+1+1+6) = 81 (old schema with embedded strings) // New cmangos schema packs each slot as a single BroadcastTextId. 17 cols: ID + 8 probs + 8 ids. @@ -155,7 +155,7 @@ mod npct { } } pub(crate) mod bt { - // broadcast_text: Id, Text (male), Text1 (female), ChatTypeID, LanguageID,... + // broadcast_text: Id, Text (male), Text1 (female), ChatTypeID, LanguageID, ... pub const ID: usize = 0; pub const TEXT: usize = 1; // male greeting text pub const TEXT1: usize = 2; // female greeting text @@ -344,7 +344,7 @@ mod it { pub const NAME: usize = 3; pub const DISPLAY_ID: usize = 4; pub const QUALITY: usize = 5; - // Flags : the raw item_template.Flags bitmask (unique/conjured/etc, ItemFlag on + // Flags: the raw item_template.Flags bitmask (unique/conjured/etc, ItemFlag on // the wire). Sits right after Quality(5), before BuyCount(7)/BuyPrice(8) — anchored by the // already-verified BUY_PRICE=8 two columns later (Quality(5), Flags(6), BuyCount(7), BuyPrice(8) // is the standard cmangos 1.12 item_template column run). @@ -360,17 +360,17 @@ mod it { // sentinel; restrictive masks are unsigned bits and must stay byte-for-byte intact. pub const ALLOWED_CLASS: usize = 11; pub const ALLOWED_RACE: usize = 12; - // RequiredSkill/RequiredSkillRank : weapon-skill / mail-plate proficiency gate. + // RequiredSkill/RequiredSkillRank: weapon-skill / mail-plate proficiency gate. // Anchored tightly between the already-verified REQUIRED_LEVEL(14) and STACKABLE(23): 14 // RequiredLevel, 15 RequiredSkill, 16 RequiredSkillRank, 17 RequiredSpell, 18 RequiredHonorRank, // 19 RequiredCityRank, 20 RequiredReputationFaction, 21 RequiredReputationRank, 22 maxcount, // 23 Stackable(confirmed) — the run lands exactly on the existing STACKABLE=23 anchor. pub const REQUIRED_SKILL: usize = 15; pub const REQUIRED_SKILL_RANK: usize = 16; - // RequiredReputationFaction/Rank : the 195 item half. Same anchored run as above. + // RequiredReputationFaction/Rank: the 195 item half. Same anchored run as above. pub const REQUIRED_REPUTATION_FACTION: usize = 20; pub const REQUIRED_REPUTATION_RANK: usize = 21; - // maxcount : unique-item stack cap. Same anchored run, one column before the + // maxcount: unique-item stack cap. Same anchored run, one column before the // already-verified STACKABLE=23. pub const MAX_COUNT: usize = 22; pub const ITEM_LEVEL: usize = 13; @@ -398,7 +398,7 @@ mod it { pub const SPELLTRIGGER_1: usize = 71; pub const SPELLID_2: usize = 77; pub const SPELLTRIGGER_2: usize = 78; - // Spell slots 3-5 : each slot is 7 columns wide, same shape as slots 1-2 above. + // Spell slots 3-5: each slot is 7 columns wide, same shape as slots 1-2 above. // Slot 3 starts right after slot 2's 7 columns (77..=83), slot 4 after slot 3's (84..=90), slot 5 // after slot 4's (91..=97) — and slot 5's own 7 columns (98..=104) land exactly on the // already-verified BONDING=105 anchor one column later, confirming all three slots. @@ -419,7 +419,7 @@ mod it { pub const RANDOM_PROPERTY: usize = 114; pub const BLOCK: usize = 115; // shield block value (CREATE TABLE: …RandomProperty(114), block(115), itemset(116)) pub const MAX_DURABILITY: usize = 117; - // BagFamily : bag-type restriction bitmask. Anchored right after the + // BagFamily: bag-type restriction bitmask. Anchored right after the // already-verified MAX_DURABILITY=117: 117 MaxDurability(confirmed), 118 area, 119 Map, // 120 BagFamily. pub const BAG_FAMILY: usize = 120; @@ -504,7 +504,7 @@ mod qt { mod dse { // dbscripts_on_quest_end — verified against the pinned dump's own `CREATE TABLE`: id, delay, // priority, command, datalong, datalong2, datalong3, buddy_entry, search_radius, data_flags, - // dataint,.... SCRIPT_COMMAND_SEND_MAIL (cm:ScriptMgr.cpp:2850-2866) is the only command this + // dataint, .... SCRIPT_COMMAND_SEND_MAIL (cm:ScriptMgr.cpp:2850-2866) is the only command this // importer reads from this table. pub const ID: usize = 0; // quest_template.entry the script fires on turn-in pub const COMMAND: usize = 3; @@ -690,7 +690,7 @@ fn go_initial_state(stored_type: u8, data0: u32) -> u8 { } mod nt { - // npc_trainer: entry(=creature_template.entry), spell, spellcost, reqskill, reqskillvalue, reqlevel,... + // npc_trainer: entry(=creature_template.entry), spell, spellcost, reqskill, reqskillvalue, reqlevel, ... pub const ENTRY: usize = 0; pub const SPELL: usize = 1; pub const COST: usize = 2; @@ -837,7 +837,7 @@ mod spe { pub const COLUMNS: usize = 9; } pub(crate) mod got { - // gameobject_template: entry, type, displayId, name, faction, flags, ExtraFlags, size, data0..23,... + // gameobject_template: entry, type, displayId, name, faction, flags, ExtraFlags, size, data0..23, ... pub const ENTRY: usize = 0; pub const TYPE: usize = 1; pub const DISPLAY_ID: usize = 2; @@ -854,7 +854,7 @@ pub(crate) mod got { pub const DATA5: usize = 13; } mod go { - // gameobject (spawn): guid, id(=gameobject_template.entry), map, spawnMask, x, y, z, orientation, rot0..3,... + // gameobject (spawn): guid, id(=gameobject_template.entry), map, spawnMask, x, y, z, orientation, rot0..3, ... pub const GUID: usize = 0; pub const ID: usize = 1; pub const MAP: usize = 2; @@ -1652,7 +1652,7 @@ fn build_start_position_sql(dump: &str) -> Vec { stmts } -/// Clear+reload SQL for `game_graveyard_zone` from cmangos `game_graveyard_zone` : the +/// Clear+reload SQL for `game_graveyard_zone` from cmangos `game_graveyard_zone`: the /// zone (+ optional faction restriction) each imported graveyard (`game_graveyard`, loaded separately /// from `WorldSafeLocs.dbc` by `dbc.rs::graveyard_sql`) serves. `module/src/world.rs`'s /// `graveyard::resolve_graveyard` reads this via the `by_zone` index to prefer a zone-linked @@ -1684,7 +1684,7 @@ fn build_graveyard_zone_sql(dump: &str) -> Vec { stmts } -/// Clear+reload SQL for `game_areatrigger_teleport` from cmangos `areatrigger_teleport` : +/// Clear+reload SQL for `game_areatrigger_teleport` from cmangos `areatrigger_teleport`: /// dungeon entrance/exit portals — trigger id → target map + destination position. `id` here is the /// SAME `AreaTrigger.dbc` trigger id `game_area_trigger` (209, `dbc.rs::area_trigger_sql`) carries as /// its primary key: 209 imports the trigger's GEOMETRY (position/shape, from the client DBC), this @@ -1887,7 +1887,7 @@ fn build_createinfo_item_sql(dump: &str, dbc_dir: Option<&str>) -> Result std::collections::HashMap2 factions. @@ -2972,7 +2972,7 @@ fn build_quests( // COLLECT objective. A dump count of 0 means 1 (cmangos treats 0 as 1; the module floors at 1). let src_item: u64 = field(&row, qt::SRC_ITEM_ID).parse().unwrap_or(0); let src_item_count: u32 = field(&row, qt::SRC_ITEM_COUNT).parse().unwrap_or(0); - // Forward chains + timed quests : NextQuestInChain (0 = no successor; clamp + // Forward chains + timed quests: NextQuestInChain (0 = no successor; clamp // negative to 0 like prev_quest above) and LimitTime seconds (0 = untimed; cmangos never emits // a negative LimitTime, but clamp defensively the same way every other numeric column here does). let next_quest_raw: i64 = field(&row, qt::NEXT_QUEST_IN_CHAIN).parse().unwrap_or(0); @@ -3310,7 +3310,7 @@ fn build_items_and_loot( vendor_id += 1; } - // 2.6) npc_vendor_template indirection : 11 spawned vendors (Corina Steele, + // 2.6) npc_vendor_template indirection: 11 spawned vendors (Corina Steele, // Brog Hamfist, …) have ZERO direct npc_vendor rows — cmangos serves their goods via // creature_template.VendorTemplateId → npc_vendor_template. Flatten that into the same // per-creature game_npc_vendor rows (our schema deliberately has no template indirection). @@ -3456,7 +3456,7 @@ fn build_items_and_loot( // BuyCount (080): floor at 1 — a 0 in the dump (or a parse miss) must not sell zero items. let buy_count: u32 = field(&row, it::BUY_COUNT).parse().unwrap_or(1).max(1); let food_type: u8 = field(&row, it::FOOD_TYPE).parse().unwrap_or(0); - // ON-USE SPELL OVERRIDE : a curated entry's spellid_1/spelltrigger_1 is OUR synthetic + // ON-USE SPELL OVERRIDE: a curated entry's spellid_1/spelltrigger_1 is OUR synthetic // on-use cast, forced trigger 0 — see `USE_SPELL_OVERRIDE`'s doc above. Every other item keeps // its raw dump value untouched (baseline-safe: the vast majority of the vanilla item catalog is // unaffected). @@ -3479,7 +3479,7 @@ fn build_items_and_loot( // ContainerSlots — how many bag slots this item provides (0 for non-bags); col 24 in // the cmangos 1.12 item_template schema, immediately after Stackable(23). cslots = field(&row, it::CONTAINER_SLOTS).parse::().unwrap_or(0), - // Bonding : 0=NoBind,1=BoP,2=BoE,3=BoU,4/5=QuestItem, mapped straight + // Bonding: 0=NoBind,1=BoP,2=BoE,3=BoU,4/5=QuestItem, mapped straight // through to the client's "Binds when picked up/equipped" tooltip line. bonding = field(&row, it::BONDING).parse::().unwrap_or(0), class = field(&row, it::CLASS), @@ -3606,7 +3606,7 @@ fn build_mail_loot( /// that `spacetime sql` rejects (400) — row-count chunking alone never splits it. No-op for empty `rows`. /// Data-row count across a family's generated statement list: DELETEs contribute 0; each /// `push_insert`-shaped INSERT contributes its VALUES tuple count (`),(` separators + 1). -/// Feeds the provenance stamps for families whose builders return statements +/// Feeds the provenance stamps for families whose builders return statements /// rather than row vectors — statement `.len()` would under-report by the batch factor. /// Only sound for numeric-payload tables (a text value containing `),(` would over-count); /// the "globals" builders (stats/positions/graveyards/createinfo) are all numeric. @@ -4061,7 +4061,7 @@ fn build_dump_plan( &creature_skin_ids, &chest_loot_ids_used, ); - // Gossip menus + NPC text + menu options : map each in-box gossip NPC's + // Gossip menus + NPC text + menu options: map each in-box gossip NPC's // creature_entry to the npc_text id shown in SMSG_GOSSIP_MESSAGE, import the resolved greeting // strings (all 8 weighted slots, not just the first), and import its clickable menu options // (vendor/innkeeper/trainer/plain-gossip, quest-status conditions folded). Four-table clear+reload. @@ -4257,7 +4257,7 @@ fn collect_creature_spawns(dump: &str, scope: &WorldImportScope) -> Vec = Vec::new(); let mut pool_resolved = 0u32; @@ -4503,7 +4503,7 @@ fn build_creature_templates( if loot_id > 0 { creature_loot_ids.insert(entry, loot_id); } - // Loot-family completeness : PickpocketLootId collapses onto creature_entry + // Loot-family completeness: PickpocketLootId collapses onto creature_entry // directly (like LootId, above); SkinLootId is recorded per-entry too but stays keyed on the // loot-id ITSELF in game_skinning_loot (several creatures share one level-banded table). let pickpocket_loot_id: u64 = field(&row, ct::PICKPOCKET_LOOT_ID).parse().unwrap_or(0); @@ -4555,7 +4555,7 @@ fn build_creature_templates( // game_creature_template column order (see module/src/creatures/spawn.rs). The trailing // `aggro_range` (proximity aggro) has no cmangos source column, so emit 0 (passive — // retaliate-only, the column default); aggression tuning is a separate content pass. - // `pickpocket_loot_id`/`skin_loot_id` are END-appended after `armor`. + // `pickpocket_loot_id`/`skin_loot_id` are END-appended after `armor`. // Trainer service columns, END-appended after `skin_loot_id`. Both parse to `u8` and fall to // 0 on an absent or unparseable cell; for `trainer_class` that 0 is the fail-open "serves // everyone" value. Not `num_or_zero`: it only rewrites an EMPTY cell and would splice a @@ -4777,7 +4777,7 @@ fn build_waypoint_rows( direct_wp_guids.insert(db_guid); wp_id += 1; } - // 4b) creature_movement_template : ENTRY-keyed paths, expanded onto every + // 4b) creature_movement_template: ENTRY-keyed paths, expanded onto every // in-slice MovementType=2 spawn of that entry that has NO direct rows (direct always wins). // Waypoint Zs — like spawn Zs — ride through VERBATIM (no terrain snap exists in the importer; // cmangos waypoints are floor-correct, which Map 36 needs while ground_z remains absent). @@ -4846,7 +4846,7 @@ fn collect_gameobject_spawns(dump: &str, scope: &WorldImportScope) -> Gameobject // curated CHEST/QUESTGIVER) did. `classify_go_type` decides the stored `type_id` (or drops the row // — the TYPE-25 COLLISION GUARD); `go_template_row` then builds the per-type SQL row. The spawns // load via the import_gameobjects reducer (game_gameobject has a Timestamp); the packed spawn row - // carries an `initial_state` field so a DOOR/BUTTON can spawn already-open. + // carries an `initial_state` field so a DOOR/BUTTON can spawn already-open. let go_tmpls = parse_table(dump, "gameobject_template"); let mut dropped_type25: Vec = Vec::new(); let go_meta: std::collections::HashMap = go_tmpls @@ -5039,7 +5039,7 @@ fn gameobject_template_rows(spawns: &GameobjectSpawns) -> GameobjectTemplateRows chest_loot_ids_used.push(loot_id); } } - // Per-type coverage : loud so an operator sees exactly what a box pulled in, which + // Per-type coverage: loud so an operator sees exactly what a box pulled in, which // types are LIVE (dispatched by `apply_use_gameobject`) vs INERT (template+spawn only, `use` no-ops). const GO_LIVE_TYPE_NAMES: &[(u8, &str)] = &[ (GO_DOOR, "DOOR"), @@ -5140,7 +5140,7 @@ fn build_trainer_spell_rows( // the Apprentice→Artisan tiers (real Spell.dbc id → SMSG_LEARNED_SPELL resolves). cost 0 (the // gossip-learn is free in vanilla). Skip a tier whose spell id already collides with a class/recipe // row for THIS trainer (so a trainer can't double-insert the same spell_id). Every column is NAMED - // (slice-5 lesson). The cap comes from the REAL client's SkillTiers.dbc data when + // (slice-5 lesson). The cap comes from the REAL client's SkillTiers.dbc data when // --dbc was given (already parity-checked equal to LEGACY_TIER_CAPS above); without --dbc there's no // client data to resolve, so it falls back to LEGACY_TIER_CAPS directly (documented fallback). let mut synthesized = 0usize; @@ -5180,7 +5180,7 @@ fn build_loot_family_rows( creature_skin_ids: &std::collections::HashMap, chest_loot_ids_used: &[u32], ) -> LootFamilyRows { - // Loot-family completeness : pickpocket / skinning / gameobject(chest) / fishing. + // Loot-family completeness: pickpocket / skinning / gameobject(chest) / fishing. // `refs` (the reference_loot_template pool map) is parsed ONCE here and shared by all four — // `parse_creature_drops` above already parsed its own copy for the creature family; a second parse // for these four is the same low-cost tradeoff this importer already accepts (content-slice sized). @@ -5699,7 +5699,7 @@ fn family_stamps( eventai, .. } = content; - // Provenance stamps : one entry per family that ACTUALLY had its block pushed + // Provenance stamps: one entry per family that ACTUALLY had its block pushed // above — mirrors exactly the `family_active` gates this function used, so a stamp only ever // corresponds to data this run's plan actually carries. let mut stamps: Vec<(&'static str, u64)> = Vec::new(); @@ -5856,7 +5856,7 @@ fn main() -> Result<()> { None => None, }; - // Optional profession tier-cap resolution : SkillRaceClassInfo.dbc + SkillTiers.dbc + // Optional profession tier-cap resolution: SkillRaceClassInfo.dbc + SkillTiers.dbc // give the REAL Apprentice→Artisan caps per profession skill line — checked immediately against // `LEGACY_TIER_CAPS` (`assert_profession_tier_parity`, bailing loudly on any disagreement) before the // trainer-synthesis pass below ever runs. Without --dbc there's no client data to check against, so @@ -5998,7 +5998,7 @@ fn main() -> Result<()> { } eprintln!("import applied."); - // Provenance stamps : `plan.stamps` already carries exactly the families whose + // Provenance stamps: `plan.stamps` already carries exactly the families whose // block was pushed (see `build_dump_plan`) — stamp each one now that the apply above succeeded. let file_hash = sha256_hex(dump.as_bytes())?; for (family, row_count) in &plan.stamps { @@ -6409,7 +6409,7 @@ mod tests { assert!(!is_drink_consumable(0, 2, "Elixir of Water Walking")); // elixir (subclass 2) excluded } - // ---- LOOT-FAMILY COMPLETENESS ---- + // ---- LOOT-FAMILY COMPLETENESS ---- /// `resolve_ref_pool` flattens a 2-DEEP reference chain (pool 1 → item A direct + a `-ref` to pool 2 /// → item B) into ONE flat list, and a CYCLE (pool 3 → pool 4 → pool 3) terminates instead of @@ -9425,7 +9425,7 @@ mod tests { #[test] fn family_quests_filter_produces_only_quest_family_stmts() { - // `--family quests` : the plan must carry ONLY the quest tables' DELETE+INSERT + // `--family quests`: the plan must carry ONLY the quest tables' DELETE+INSERT // — no creature/item/loot/gossip/gameobject/trainer/cast/globals statements — even though every // family's prerequisite parsing still ran (the quest giver relation needed the creature entry // to resolve, per `build_quests`' doc). diff --git a/importer/src/nav.rs b/importer/src/nav.rs index 179b0a533..e2a3329a6 100644 --- a/importer/src/nav.rs +++ b/importer/src/nav.rs @@ -45,7 +45,7 @@ pub(crate) type Tri = [[f32; 3]; 3]; // Geometry extraction // --------------------------------------------------------------------------------------------- -/// One WMO collision triangle plus the group it came from, `vmap.rs` packs this +/// One WMO collision triangle plus the group it came from, `vmap.rs` packs this /// per-triangle metadata (source class + group id + MOGP flags); `nav.rs`'s rasterizer only /// needs `.tri`. pub(crate) struct WmoTri { @@ -475,7 +475,7 @@ fn rasterize_cell(cell: &crate::terrain::CellRow, tris: &[VmapTri]) -> Option u8 { // the inert A_FLAG so the regen gate can read the magnitude without a spell-id or race check. ModRegenDuringCombat | ModHealthRegenInCombat => A_COMBAT_HEALTH_REGEN_PCT, - // Demon Skin/Armor's health-per-5 : aura 84 SPELL_AURA_MOD_REGEN + // Demon Skin/Armor's health-per-5: aura 84 SPELL_AURA_MOD_REGEN // is a COMBAT-INDEPENDENT periodic heal tick (it heals a living target on a fixed period // whether or not it is in combat) — the same primitive already // wired for Renew/bandages/food. Reclassified from the inert A_FLAG marker onto A_PERIODIC_HEAL; @@ -3125,7 +3125,7 @@ mod tests { assert_eq!(stance_p0(E_SET_STANCE, 17), 0); // Battle Stance → stance 0 assert_eq!(stance_p0(E_SET_STANCE, 18), 1); // Defensive Stance → stance 1 assert_eq!(stance_p0(E_SET_STANCE, 19), 2); // Berserker Stance → stance 2 - // Druid combat forms : Bear Form 5487 carries ModShapeshift(5), Cat Form 768 + // Druid combat forms: Bear Form 5487 carries ModShapeshift(5), Cat Form 768 // carries form 1, Dire Bear Form 9634 carries form 8. assert_eq!(stance_p0(E_SET_STANCE, 5), 3); // Bear Form → stance 3 assert_eq!(stance_p0(E_SET_STANCE, 1), 4); // Cat Form → stance 4 @@ -3530,7 +3530,7 @@ mod tests { use AuraMod::*; assert_eq!(aura_mod_to_kind(PeriodicDamage), A_PERIODIC_DAMAGE); assert_eq!(aura_mod_to_kind(PeriodicHeal), A_PERIODIC_HEAL); - // Demon Skin/Armor's health-per-5 : ModRegen is a combat-independent periodic + // Demon Skin/Armor's health-per-5: ModRegen is a combat-independent periodic // heal, reclassified onto the SAME A_PERIODIC_HEAL kind as PeriodicHeal (Renew/bandages/food) — // NOT left as the inert A_FLAG marker. assert_eq!(aura_mod_to_kind(ModRegen), A_PERIODIC_HEAL); @@ -3547,7 +3547,7 @@ mod tests { // incoming-damage % modifier (Shield Wall / vulnerability) — wired; the FLAT variant stays no-op. assert_eq!(aura_mod_to_kind(ModDamagePercentTaken), A_MOD_DAMAGE_TAKEN); assert_eq!(aura_mod_to_kind(ModDamageTaken), E_SCRIPTED); // flat damage-taken not handled yet - // Spell modifiers : AddFlatModifier/AddPctModifier were the "unmapped → no-op" + // Spell modifiers: AddFlatModifier/AddPctModifier were the "unmapped → no-op" // example here until the passive-modifier engine landed (2000292) and gave them real kinds — // this assertion is the regression guard for that reclassification, not a stale count. assert_eq!(aura_mod_to_kind(AddFlatModifier), A_SPELLMOD_FLAT); diff --git a/module/src/action_bar.rs b/module/src/action_bar.rs index 99b407b5e..54f0e7582 100644 --- a/module/src/action_bar.rs +++ b/module/src/action_bar.rs @@ -1,7 +1,7 @@ //! Creation-time action-bar rows — `game_player_action` is the per-character bar the gateway's login //! sequence reads FIRST (before falling back to synthesizing a bar from the spellbook, see //! `gateway/src/codec/entity.rs::login_sequence_messages`). Mirrors the `game_createinfo_spell` / -//! `game_player_spell` precedent one level over: `game_createinfo_action` is the +//! `game_player_spell` precedent one level over: `game_createinfo_action` is the //! per-(race, class) dump-sourced default layout (`playercreateinfo_action`), copied into //! `game_player_action` at character creation exactly like `grant_createinfo_spells` copies the spell //! kit. UNLIKE the spell kit there is no hand-authored fallback seeded into `game_createinfo_action` — diff --git a/module/src/auth.rs b/module/src/auth.rs index 18d9c7d04..12bf76f77 100644 --- a/module/src/auth.rs +++ b/module/src/auth.rs @@ -1050,7 +1050,7 @@ pub(crate) fn insert_new_character( // the one castability source (`knows_spell`) and the gateway's SMSG_INITIAL_SPELLS both read them. let owner = account.identity.unwrap_or(Identity::ZERO); crate::spell::spellbook::grant_createinfo_spells(ctx, next_guid, owner, race, class); - // Action-bar rows : copies `game_createinfo_action` the same way the spell grant + // Action-bar rows: copies `game_createinfo_action` the same way the spell grant // above copies `game_createinfo_spell` — a no-op pre-import (table empty), so the gateway's login // synth covers it until the dump is imported. crate::action_bar::grant_createinfo_actions(ctx, next_guid, owner, race, class); diff --git a/module/src/character.rs b/module/src/character.rs index c546de996..7ea7080fc 100644 --- a/module/src/character.rs +++ b/module/src/character.rs @@ -105,7 +105,7 @@ pub struct Character { #[default(0u64)] pub pending_instance_id: u64, - /// GM playtest authorization level : `0` = no access to any `.command`; the + /// GM playtest authorization level: `0` = no access to any `.command`; the /// operator-only `gm::set_gm_level` reducer is the only writer. Moderation-facing per-level /// distinctions beyond "has access at all" are's concern, not this one's, every /// `gm_command` today only checks `gm_level != 0`. `#[default(0)]` + END-appended so `publish` diff --git a/module/src/combat/death.rs b/module/src/combat/death.rs index f9e6d6352..cc549468f 100644 --- a/module/src/combat/death.rs +++ b/module/src/combat/death.rs @@ -499,7 +499,7 @@ pub fn debug_verify_lethal_damage_floor_fixture( Ok(()) } -// Tables' pure formulas/consts and the sibling submodules' re-exports (`roll_money`, `is_engaged`,...) +// Tables' pure formulas/consts and the sibling submodules' re-exports (`roll_money`, `is_engaged`, ...) // are all pulled in from `mod.rs` (`pub use tables::*` + `pub use folds::*`/`engage::*`/`swing::*`) so // every symbol resolves the same as before the split. use super::*; @@ -648,7 +648,7 @@ fn kill_creature_with_attribution( victim_is_player: false, }, ); - // Encounter kernel : the entry-keyed, instance-stamped creature death, the + // Encounter kernel: the entry-keyed, instance-stamped creature death, the // pet clean-despawn branch above deliberately does NOT fire this (encounters key on wild // creatures). Snapshots were taken before the corpse row was mutated. crate::hooks::fire_on_creature_death( @@ -794,7 +794,7 @@ fn roll_corpse_loot( // A valid Loot Tag rolls quest-only rows with ordinary corpse loot. Visibility and takability // remain per-viewer decisions downstream, so `roll_creature_loot` needs no killing source. let dropped = crate::loot::roll_creature_loot(ctx, target.entry, target_guid); - // Group loot methods : a GROUPED kill's above-threshold rows may + // Group loot methods: a GROUPED kill's above-threshold rows may // spawn a need/greed roll, below-threshold/round-robin rows get a designated looter stamped, and // above-threshold-under-MASTER rows get restricted to the master — all decided HERE at kill // time (see loot.rs's module doc for why not lazily "at loot-open"). A no-op for an ungrouped diff --git a/module/src/combat/swing.rs b/module/src/combat/swing.rs index 49f52655b..f2b3c3855 100644 --- a/module/src/combat/swing.rs +++ b/module/src/combat/swing.rs @@ -15,7 +15,7 @@ use crate::{ }; // Tables' pure formulas/consts and the sibling submodules' re-exports (`roll_swing`, `apply_hit`, -// `enter_combat`,...) are all pulled in from `mod.rs` (`pub use tables::*` + `pub use +// `enter_combat`, ...) are all pulled in from `mod.rs` (`pub use tables::*` + `pub use // folds::*`/`death::*`/`engage::*`) so every symbol resolves the same as before the split. use super::*; diff --git a/module/src/config.rs b/module/src/config.rs index f295cbcd5..8b94fec21 100644 --- a/module/src/config.rs +++ b/module/src/config.rs @@ -47,7 +47,7 @@ pub struct ServerConfig { #[primary_key] pub id: u32, // singleton: always 0 pub xp_rate: f32, - // END-APPENDED : nav-grid consumption gate (chase pathing + aggro/cast/melee + // END-APPENDED: nav-grid consumption gate (chase pathing + aggro/cast/melee // LoS). Default ON since 244 passed (benchmark: nav cost indistinguishable; live: all four // wall/fence/chase/hold-fire scenarios). A world WITHOUT nav data imported behaves exactly // as before either way (missing chunk = no obstacles known). Toggle: `debug_set_nav_enabled` diff --git a/module/src/corpse.rs b/module/src/corpse.rs index e2cc2f51c..38418b245 100644 --- a/module/src/corpse.rs +++ b/module/src/corpse.rs @@ -160,7 +160,7 @@ pub struct Corpse { #[default(false)] pub is_bones: bool, - // Which instance the death happened in : stamped from the dying + // Which instance the death happened in: stamped from the dying // player's own entity in `do_repop`; 0 = open world (every existing row auto-migrates to 0). // Gates reclaim (below) and the gateway's corpse CREATE relay by viewer instance; the instance // reap deletes any corpse left inside (the ghost's outcome is then spirit-healer-only — diff --git a/module/src/creatures/ai.rs b/module/src/creatures/ai.rs index 6a28d7818..ea847675e 100644 --- a/module/src/creatures/ai.rs +++ b/module/src/creatures/ai.rs @@ -422,7 +422,7 @@ pub(crate) const SENSE_EVERY_N_TICKS: i64 = 8; /// The target SENSE period in micros (~4s) — `MOVE_TICK_MICROS × SENSE_EVERY_N_TICKS`. A tick row of /// ANY cadence quantizes its sensing passes to roughly this period (see -/// `is_sense_tick_for_interval`), so a tight per-instance tick smooths MOVEMENT +/// `is_sense_tick_for_interval`), so a tight per-instance tick smooths MOVEMENT /// latency without also multiplying the expensive sensing scans or the tick-quantized effect rates /// (wander hop chance, aggro re-checks) that assume the ~4s cadence. pub(crate) const SENSE_PERIOD_MICROS: i64 = MOVE_TICK_MICROS * SENSE_EVERY_N_TICKS; @@ -474,7 +474,7 @@ pub fn sense_period_secs_for_interval(interval_micros: i64) -> f32 { } // =========================================================================================== -// Per-instance tick scoping [pure] +// Per-instance tick scoping [pure] // =========================================================================================== // diff --git a/module/src/creatures/spawn.rs b/module/src/creatures/spawn.rs index 2ded20e65..1a0a3034b 100644 --- a/module/src/creatures/spawn.rs +++ b/module/src/creatures/spawn.rs @@ -78,7 +78,7 @@ pub struct CreatureTemplate { #[default(0)] pub armor: u32, - // Loot-family completeness : the creature's PICKPOCKET and SKIN loot-table ids , + // Loot-family completeness: the creature's PICKPOCKET and SKIN loot-table ids, // cmangos `creature_template.PickpocketLootId` / `SkinLootId`, sitting immediately after `LootId` // in the real schema (the importer's `ct::PICKPOCKET_LOOT_ID`/`ct::SKIN_LOOT_ID`, `[V]` — confirm // against your own dump). `pickpocket_loot_id` keys `game_pickpocket_loot` directly by CREATURE @@ -268,7 +268,7 @@ pub struct NpcTextSlot { pub probability: f32, } -/// Per-creature gossip MENU OPTION : a clickable line in `SMSG_GOSSIP_MESSAGE` +/// Per-creature gossip MENU OPTION: a clickable line in `SMSG_GOSSIP_MESSAGE` /// (browse-goods / make-home / "Train me" / plain gossip text / submenu link), imported from cmangos /// `gossip_menu_option`. Keyed by creature template `entry` (NOT the cmangos `gossip_menu_option.menu_id` /// — the importer collapses the same menu→entry indirection `GossipMenu` already collapses for the @@ -285,7 +285,7 @@ pub struct NpcTextSlot { /// `lyracore_shared::constants::gossip_condition`; an option the importer can't classify gets `cond_type = 0` /// (fail-open, always shown) rather than silently hidden, and is logged at import time. /// -/// A NEW table → auto-migrates with no `-c`. No Timestamp → SQL-seedable, +/// A NEW table → auto-migrates with no `-c`. No Timestamp → SQL-seedable, /// importer-owned (clear+reload each ETL run, like its sibling `GossipMenu`). [static] #[table(accessor = game_gossip_option, public, index(accessor = by_entry, btree(columns = [entry])))] pub struct GossipOption { @@ -874,7 +874,7 @@ pub fn build_player_entity( death_expire_micros: character.death_expire_micros, instance_id: character.pending_instance_id, - // GM playtest fields threaded from the DURABLE carry columns , + // GM playtest fields threaded from the DURABLE carry columns, // the `death_expire_micros` precedent). They are NOT durable settings: `persist_entity` clears // them on a real logout/disconnect and carries them across a despawn/rebuild WITHIN a session // (`persisted_gm_playtest`), so a login still starts at 1× speed / not-godmode while a diff --git a/module/src/creatures/tick/mod.rs b/module/src/creatures/tick/mod.rs index 0d2b3afe4..1b48b6c20 100644 --- a/module/src/creatures/tick/mod.rs +++ b/module/src/creatures/tick/mod.rs @@ -170,7 +170,7 @@ pub struct CreatureSpline { /// ai.rs is the coverage rule — a partition, never an overlap, so a second row DIVIDES the per-firing /// work instead of multiplying it). /// -/// HONEST BOUND : SpacetimeDB serializes every reducer on ONE commit stream, this is +/// HONEST BOUND: SpacetimeDB serializes every reducer on ONE commit stream, this is /// LATENCY SMOOTHING + WORK AVOIDANCE, **NOT parallelism**. Each extra row's firings preempt the /// shared stream (10 instances at 100ms = 100 extra transactions/sec), so tight per-instance /// cadences are a knob to use sparingly, measured via the per-pass rows-visited log below. @@ -271,7 +271,7 @@ pub fn tick_creatures(ctx: &ReducerContext, schedule: CreatureMoveSchedule) { } // =========================================================================================== -// Active cells [server] ,: grid-activation; only cells near players tick +// Active cells [server],: grid-activation; only cells near players tick // =========================================================================================== /// Rough heartbeat period (micros) for the active-cell rows-visited log line, the @@ -483,9 +483,9 @@ fn log_pass_stats( ); } -/// The ONE shared creature move-leg writer : every movement decision (the cycle's +/// The ONE shared creature move-leg writer: every movement decision (the cycle's /// idle and chase legs, flee, fear-flee) funnels its ALREADY-STEPPED landing point through here, so a -/// single ground-snap / anti-desync fix applies to ALL of them at once. The per-pass +/// single ground-snap / anti-desync fix applies to ALL of them at once. The per-pass /// STEP is computed by the caller BEFORE this call (different math per pass — waypoint segment / chase /// step / walk-home / wander hop / flee dash); this owns only what every pass shares: /// 1. ground-snap the landing point (`snap_z`) — off-slice / unimported areas fall back to the @@ -692,7 +692,7 @@ pub(crate) fn emit_creature_leg( ); return; } - // ONE WRITER : funnel the row build + upsert through `emit_move_spline`, the + // ONE WRITER: funnel the row build + upsert through `emit_move_spline`, the // SAME call the cycle's spline-advance halt and its chase stop // already use, so "one spline writer" is a fact the type system enforces, not doctrine repeated at // each call site. Was: a `game_creature_move_event` INSERT (globally subscribed — so every leg was diff --git a/module/src/debug/instance.rs b/module/src/debug/instance.rs index 8c34cd0a0..93ccebe7c 100644 --- a/module/src/debug/instance.rs +++ b/module/src/debug/instance.rs @@ -1041,7 +1041,7 @@ pub fn debug_bench_collision_gate( ); } -/// Backfill the imported default action bar onto an EXISTING character , +/// Backfill the imported default action bar onto an EXISTING character, /// chars created before the `game_createinfo_action` import have no `game_player_action` rows and /// fall back to the gateway's known-spells synth (which slots passives and misses the stance /// pages). Idempotent per button (the grant skips occupied ones). Takes effect next login. diff --git a/module/src/debug/mod.rs b/module/src/debug/mod.rs index db2d22fc7..804d854f4 100644 --- a/module/src/debug/mod.rs +++ b/module/src/debug/mod.rs @@ -668,7 +668,7 @@ pub fn debug_sell_item( /// Enter AreaTrigger `trigger_id` as `character_guid` — drives `enter_areatrigger`'s shared core /// (`apply_enter_areatrigger`) by explicit guid for the harness: credits any "explore" quest tied to -/// the trigger, AND routes through a cross-map teleport if `trigger_id` is an imported +/// the trigger, AND routes through a cross-map teleport if `trigger_id` is an imported /// `game_areatrigger_teleport` row — the same live runbook this debug twin drives for 224 exercises the /// Deadmines portal end-to-end without a physical CMSG_AREATRIGGER from the client. #[reducer] @@ -794,7 +794,7 @@ pub fn debug_spawn_gameobject( } let guid = (0xF110u64 << 48) | template_entry as u64; ctx.db.game_gameobject().guid().delete(guid); - // Clear any prior Pick-Lock unlock for this DERIVED guid so a re-spawn of a locked + // Clear any prior Pick-Lock unlock for this DERIVED guid so a re-spawn of a locked // template is deterministically LOCKED again (the verify's negative case relies on this). ctx.db.game_gameobject_unlocked().go_guid().delete(guid); let go = ctx @@ -1838,7 +1838,7 @@ const INSTANCE_TICK_MS_FLOOR: u64 = 50; const INSTANCE_TICK_MS_CEIL: u64 = 600_000; -/// Arm (or re-arm) a DEDICATED creature-tick row for `instance_id` at `tick_ms` : that +/// Arm (or re-arm) a DEDICATED creature-tick row for `instance_id` at `tick_ms`: that /// instance's creature passes then fire on THIS row at its own cadence, and the catch-all row skips /// the instance (coverage is a partition — see `TickScope` in creatures/ai.rs). The global due-time /// passes (decay/respawn/regen/combat-drop) STAY on the catch-all row for all instances, so this knob diff --git a/module/src/encounter.rs b/module/src/encounter.rs index 54f1ceda0..d5da90ccb 100644 --- a/module/src/encounter.rs +++ b/module/src/encounter.rs @@ -453,7 +453,7 @@ pub fn set_encounter_data( // =========================================================================================== /// Register "fire `on_hp_threshold` when a creature of `entry` reaches `pct`% or below" — the -/// package-side half of the hook (the `game_hook!(on_hp_threshold,...)` handler is the other). +/// package-side half of the hook (the `game_hook!(on_hp_threshold, ...)` handler is the other). /// Idempotent (check-before-insert, the `learn_spell` grant idiom): packages typically call this /// from their seed/fixture reducer or an `on_creature_spawn` handler, either of which may run /// repeatedly. `pct` must be 1..=99 (0 is the death hook's job; 100 would fire on any scratch). diff --git a/module/src/gameobject.rs b/module/src/gameobject.rs index 0af90f932..6f9532309 100644 --- a/module/src/gameobject.rs +++ b/module/src/gameobject.rs @@ -1,6 +1,6 @@ //! Gameobjects — the world's interactive props. A spawned `game_gameobject` is relayed to clients as a //! GameObject CREATE_OBJECT (gateway); `use_gameobject` (CMSG_GAMEOBJ_USE) dispatches by template type: -//! a CHEST rolls its real `game_gameobject_loot` table into the shared +//! a CHEST rolls its real `game_gameobject_loot` table into the shared //! `game_corpse_loot` table KEYED ON THE GO GUID (so the existing corpse loot window + take path serve //! it unchanged) — falling back to the legacy single `data0` drop when the chest has no data-driven //! table (the seed/demo chest) — a GOOBER grants quest credit for a USE_GAMEOBJECT objective, a @@ -46,7 +46,7 @@ pub mod go_type { /// and never collides with the CHEST loot-window path. `data0` = item entry granted; `data1` = /// required skill level; `gather_skill_line` = MINING/HERBALISM the use requires. pub const GATHER: u8 = 25; // synthetic; vanilla has no type 25 — our gather marker - // COLLISION NOTE : real cmangos type 25 IS assigned (GAMEOBJECT_TYPE_FISHINGHOLE) , + // COLLISION NOTE: real cmangos type 25 IS assigned (GAMEOBJECT_TYPE_FISHINGHOLE), // it just happens to collide with this synthetic marker. The importer's TYPE-25 COLLISION GUARD // (`importer/src/main.rs::classify_go_type`) is the ONLY place that decides what gets stored as // module type 25: a real FISHINGHOLE row is dropped from import, never stored here as GATHER. @@ -151,7 +151,7 @@ pub struct GameLock { pub required_skill: u32, // kind 2: the skill VALUE needed; kind 1: unused (Lock.dbc's own 0/garbage) } -/// `game_lock.kind` values : the two opener kinds [`GameLock`] models. +/// `game_lock.kind` values: the two opener kinds [`GameLock`] models. pub(crate) const LOCK_KIND_ITEM: u8 = 1; // property = the key item entry the opener must HOLD pub(crate) const LOCK_KIND_SKILL: u8 = 2; // property = a SkillLine id (Lockpicking 633 / Herbalism 182 / Mining 186) @@ -786,7 +786,7 @@ fn use_resolved_gameobject( (go.guid, go.template_entry, go.instance_id); match tmpl.type_id { go_type::CHEST => { - // LOCK gate : a CHEST whose lock has a REAL opener (`locked_shut`) refuses + // LOCK gate: a CHEST whose lock has a REAL opener (`locked_shut`) refuses // the loot roll until Pick Lock (`apply_pick_lock`) records a `game_gameobject_unlocked` row. // lock_id 0 / a hand-open (property==0-only) lock opens freely (unchanged). BEFORE the loot. if locked_shut(ctx, go.guid, tmpl.lock_id) { @@ -915,10 +915,10 @@ fn use_resolved_gameobject( } } go_type::DOOR | go_type::BUTTON => { - // DOOR/BUTTON toggle : open↔closed is state 0↔1, the same field the + // DOOR/BUTTON toggle: open↔closed is state 0↔1, the same field the // gateway already relays in the CREATE_OBJECT (`gameobject_state`) — see the new // `on_go_update` relay (subscriptions.rs) that re-emits it live, mirroring the corpse - // body→bones re-emit. LOCK gate : a DOOR/BUTTON whose lock has a REAL opener + // body→bones re-emit. LOCK gate: a DOOR/BUTTON whose lock has a REAL opener // (`locked_shut`) refuses the toggle until Pick Lock records a `game_gameobject_unlocked` // row. lock_id 0 / a hand-open (property==0-only) lock toggles freely (unchanged, per 211). if locked_shut(ctx, go.guid, tmpl.lock_id) { @@ -934,7 +934,7 @@ fn use_resolved_gameobject( // at least usable/clickable without a hard error, not a silent capability promise. } } - // Encounter kernel notify hook : every SUCCESSFUL use, the gates above (range, + // Encounter kernel notify hook: every SUCCESSFUL use, the gates above (range, // already-looted, skill) `?`-returned before this line, so a rejected use never fires. crate::hooks::fire_on_go_used( ctx, @@ -1199,7 +1199,7 @@ mod tests { assert_eq!(go_type::GATHER, 25); assert_ne!(go_type::GATHER, go_type::CHEST); assert_ne!(go_type::GATHER, go_type::GOOBER); - // DOOR/BUTTON sit at cmangos' real 0/1, guard them too, and confirm none of + // DOOR/BUTTON sit at cmangos' real 0/1, guard them too, and confirm none of // the five LIVE type ids collide with each other. assert_eq!(go_type::DOOR, 0); assert_eq!(go_type::BUTTON, 1); diff --git a/module/src/gc.rs b/module/src/gc.rs index d9088f45e..f7ebf9b10 100644 --- a/module/src/gc.rs +++ b/module/src/gc.rs @@ -160,11 +160,11 @@ pub fn reap_movement_events(ctx: &ReducerContext, _schedule: EventReaperSchedule } } - // CC diminishing-returns windows : rows whose 15s post-removal window has + // CC diminishing-returns windows: rows whose 15s post-removal window has // elapsed are dead state — the next same-category CC starts fresh at level 1 anyway. crate::spell::stacking::sweep_dr_state(ctx); - // Need/greed roll deadlines : resolves any roll whose 60s window lapsed with + // Need/greed roll deadlines: resolves any roll whose 60s window lapsed with // votes outstanding (absent members auto-pass) — the deadline half of resolve-exactly-once. crate::loot::sweep_loot_rolls(ctx); diff --git a/module/src/gm.rs b/module/src/gm.rs index a97e54694..47892d446 100644 --- a/module/src/gm.rs +++ b/module/src/gm.rs @@ -1,4 +1,4 @@ -//! GM playtest dot-commands : `.speed/.god/.xprate/.level/.money/.heal/.kill/.tele` +//! GM playtest dot-commands: `.speed/.god/.xprate/.level/.money/.heal/.kill/.tele` //! from Say chat. The gateway's `CMSG_MESSAGECHAT` Say arm intercepts any message starting with `.` //! BEFORE the normal chat relay/insert and forwards the raw text to this ONE generic reducer //! (`gm_command`) — module-side parsing keeps the command set data-free and easily extended (a new diff --git a/module/src/graveyard.rs b/module/src/graveyard.rs index c83f18824..3807e0719 100644 --- a/module/src/graveyard.rs +++ b/module/src/graveyard.rs @@ -8,7 +8,7 @@ use spacetimedb::{table, ReducerContext, Table}; /// One `WorldSafeLocs.dbc` row (a graveyard's fixed position) — imported by the importer's `--dbc` /// mode (see `importer/src/dbc.rs::graveyard_sql`). Replaces the hardcoded -/// `{NORTHSHIRE, GOLDSHIRE,...}` consts below as the primary data source once imported; those +/// `{NORTHSHIRE, GOLDSHIRE, ...}` consts below as the primary data source once imported; those /// consts remain as the no-import fallback (`nearest`), and `seed.rs` row-seeds the SAME five points /// here too, mirroring the `game_start_position` seed/import precedent (init seeds, import /// clear+reloads over it). No orientation column — `WorldSafeLocs.dbc` carries none (a graveyard @@ -112,7 +112,7 @@ const STATIC_CANDIDATES: [Graveyard; 5] = [ WESTFALL_COAST, ]; -// ---- Instance-map release -------------------------------------------------- +// ---- Instance-map release -------------------------------------------------- // pub(crate) fn instance_release_zone(map_id: u32) -> Option { diff --git a/module/src/group.rs b/module/src/group.rs index 78f846c2e..dc25a4e3a 100644 --- a/module/src/group.rs +++ b/module/src/group.rs @@ -3370,7 +3370,7 @@ mod tests { } } - // ---- Group loot methods ---- + // ---- Group loot methods ---- #[test] fn loot_method_encoding_matches_the_real_wire_grouplootsetting_order() { diff --git a/module/src/helpers.rs b/module/src/helpers.rs index 1585c9732..493f47a6b 100644 --- a/module/src/helpers.rs +++ b/module/src/helpers.rs @@ -73,11 +73,11 @@ pub fn acting_entity_by_guid(ctx: &ReducerContext, guid: u64) -> Option(ctx: /// &ReducerContext, guid: u64, identity: Identity)`. /// -/// `character_owned!(transfer, fn (, , ) { table =.., by =..,.. })` and +/// `character_owned!(transfer, fn (, , ) { table =.., by =.., .. })` and /// `character_owned!(not_transported, fn ())` — the CROSS-DATABASE row transport, /// which is what lets a table's rows actually leave one SpacetimeDB database and arrive in /// another. Both expand to `pub(crate) fn (ctx: &ReducerContext, guid: u64, io: &mut @@ -304,7 +304,7 @@ mod exploration; // feature, so they exist on production databases too; see `debug/mod.rs`. #[cfg(feature = "debug_reducers")] mod debug; -// Encounter kernel : instance-scoped encounter state, HP-threshold/death/GO-use +// Encounter kernel: instance-scoped encounter state, HP-threshold/death/GO-use // hooks, and the shared choreography primitives Packages consume (`packages/dungeons/` owns it). mod duel; mod encounter; @@ -318,7 +318,7 @@ mod go_collider; #[cfg(feature = "debug_reducers")] mod go_collider_fixture; mod go_model; -// Graveyard resolution : the death-release subsystem `world::do_repop` calls to +// Graveyard resolution: the death-release subsystem `world::do_repop` calls to // pick where a ghost teleports. Extracted from `world.rs`. mod graveyard; mod group; @@ -327,7 +327,7 @@ mod gw; mod helpers; pub mod hooks; mod import_meta; -// Dungeon-instancing lifecycle : game_instance/game_instance_binding, +// Dungeon-instancing lifecycle: game_instance/game_instance_binding, // the areatrigger resolve-or-create entry, per-instance population spawn, and the reset/reap loop. mod combo; mod instance; diff --git a/module/src/loot/mod.rs b/module/src/loot/mod.rs index 6a7e29e22..544f4f760 100644 --- a/module/src/loot/mod.rs +++ b/module/src/loot/mod.rs @@ -61,7 +61,7 @@ pub struct CorpseLoot { // non-quest drop) keeps behaving exactly as before — additive auto-migration. #[default(false)] pub quest_only: bool, - // END-APPENDED : `0` = the SHARED, not-yet-split row, any currently-needing + // END-APPENDED: `0` = the SHARED, not-yet-split row, any currently-needing // character may claim it, which triggers the per-member clone (`clone_quest_loot_for_group`); // nonzero = a character guid, this specific row is a pernted the moment the shared row is first taken). Meaningless when `quest_only` is // false UNLESS it's a group-loot WINNER-LOCKED row (below), GENERALIZED slices @@ -114,7 +114,7 @@ pub struct CreatureLoot { pub chance_bp: u32, // drop chance in basis points (0..=10000); a group member's weight if group_id>0 pub count: u32, pub group_id: u32, // 0 = independent roll; >0 = pick-one group (≤1 member drops per kill) - // END-APPENDED : cmangos `ChanceOrQuestChance < 0`, the item is a QUEST-drop, only + // END-APPENDED: cmangos `ChanceOrQuestChance < 0`, the item is a QUEST-drop, only // ever visible to a player who currently needs it (an active matching COLLECT_ITEM objective — see // `killer_needs_item`). Gated at ROLL TIME against the CREDITED KILLER (module doc, decision), // never re-evaluated later. `#[default(false)]` so existing rows (imported pre-210, or seeded) keep @@ -227,7 +227,7 @@ pub(crate) fn roll_loot_rows( .collect() } -/// Pure gate : may `taker_guid` claim a `quest_only` row whose current +/// Pure gate: may `taker_guid` claim a `quest_only` row whose current /// reservation is `reserved_for`, given `taker_needs_item` (already resolved by the caller — mirrors /// `needs_item_pure`'s ctx/pure split)? An UNRESERVED row (`reserved_for == 0` — the shared row nobody /// has split yet) is claimable by anyone who currently needs it; a RESERVED row is claimable by its @@ -264,7 +264,7 @@ pub(crate) fn group_loot_take_allowed( designated_looter_guid == 0 || designated_looter_guid == taker_guid } -/// Pure : given `(character_guid, currently_needs_item)` pairs for every OTHER +/// Pure: given `(character_guid, currently_needs_item)` pairs for every OTHER /// group member (the taker already excluded by the caller), which should receive a fresh per-member /// clone when the shared `quest_only` row is first taken? Only the still-needing ones — a member who's /// already capped on the item (or no longer holds the quest) gets nothing, matching vanilla (the item @@ -277,7 +277,7 @@ pub(crate) fn clone_targets(other_members: &[(u64, bool)]) -> Vec { .collect() } -/// Pure : `count` fresh, ascending loot-window slot indices past whatever is +/// Pure: `count` fresh, ascending loot-window slot indices past whatever is /// already used on a corpse (`used_slots`), so per-member quest clones never collide with the /// just-deleted shared row's slot or any sibling drop. Saturating (a corpse loot window is /// realistically far under 255 rows — the gateway's RAW `SMSG_LOOT_RESPONSE` builder already caps the @@ -465,7 +465,7 @@ pub(crate) fn insert_corpse_rows( count, quest_only, reserved_for: 0, - // Group-loot stamping happens AFTER this fn returns, in + // Group-loot stamping happens AFTER this fn returns, in // `apply_group_loot_rules` (combat::kill_creature) — this insert always starts FFA. designated_looter_guid: 0, master_only: false, @@ -1089,7 +1089,7 @@ mod tests { assert!(scale_money_for_rank(u32::MAX, 3) >= u32::MAX / 2); } - // ---- LOOT-FAMILY COMPLETENESS ---- + // ---- LOOT-FAMILY COMPLETENESS ---- /// `needs_item_pure` — the pure decision behind `killer_needs_item`: a COLLECT_ITEM objective on an /// TIVE quest matching `item` says yes; a KILL_CREATURE objective (wrong kind), a different item @@ -1329,7 +1329,7 @@ mod tests { assert_eq!(next_free_slots(&[255], 2), vec![255, 255]); } - // ---- Money-loot split ---- + // ---- Money-loot split ---- /// SOLO passthrough: an empty `recipients` slice (no `game_corpse_loot_eligible` snapshot) always /// credits the WHOLE amount to the looter in a single entry — byte-identical to the pre-221 diff --git a/module/src/motion.rs b/module/src/motion.rs index 7f2e5e679..a87ac39f1 100644 --- a/module/src/motion.rs +++ b/module/src/motion.rs @@ -149,7 +149,7 @@ pub(crate) enum Coalesce { /// Publish the QUEUED one immediately (in this movement transaction, paying one sweep) and /// stage the new one. This is the only path that still costs a per-packet sweep, and it is /// reachable only when a player produces two input-state changes inside one tick window — with - /// the gateway's own 150 ms heartbeat coalescing upstream, that is rare and + /// the gateway's own 150 ms heartbeat coalescing upstream, that is rare and /// self-limiting, never proportional to player count. FlushThenReplace, } diff --git a/module/src/professions.rs b/module/src/professions.rs index 0a730d346..d3ffcdc15 100644 --- a/module/src/professions.rs +++ b/module/src/professions.rs @@ -163,7 +163,7 @@ pub(crate) fn skin_corpse( return Err("corpse still has loot".to_string()); } - // Grant leather, DATA-DRIVEN : the corpse's creature template names a + // Grant leather, DATA-DRIVEN: the corpse's creature template names a // `skin_loot_id` (cmangos `skinning_loot_template`, level-banded across many creatures sharing a // band); roll it and grant every winner. `skin_loot_id == 0` (unimported / a seeded/test beast) OR // an empty/no-win roll falls back to the flat `LEATHER_ENTRY`/`LEATHER_COUNT` — byte-identical to @@ -364,7 +364,7 @@ pub(crate) fn apply_disenchant(ctx: &ReducerContext, guid: u64, slot: u8) -> Res /// ENCHANT the item in `target_slot` with `enchant_id`: validate the id (it must be a known enchant in /// `game_item_enchantment`), consume the enchanting mats, stamp `enchant_id` onto the instance, climb Enchanting. /// The core (resolved guid), shared by the `enchant_item` reducer + `debug_enchant_item` twin. The enchant -/// is server-REAL: `equipped_stat_bonus` now folds `enchant_stat(enchant_id,..)`, so an equipped enchanted +/// is server-REAL: `equipped_stat_bonus` now folds `enchant_stat(enchant_id, ..)`, so an equipped enchanted /// piece moves the effective-* readouts (swing/dodge/armor/crit/hit + max HP/mana via recompute_vitals). /// The client glow/green-text is DEFERRED (the 5875 client caches item stats by ENTRY). ROLLBACK: /// `remove_items` (the mats consume) is `?`-propagated BEFORE the instance is updated, so a missing-mats diff --git a/module/src/quest.rs b/module/src/quest.rs index 0a7b1a8eb..aeefa14b2 100644 --- a/module/src/quest.rs +++ b/module/src/quest.rs @@ -7,10 +7,10 @@ //! Data model — a clean header + list shape (NOT cmangos's one fat row), so the runtime joins are //! direct and the importer maps cmangos's flattened columns onto normalized rows: //! -//! - [`QuestTemplate`] , the quest header (level gate, title, money/XP reward). [static] +//! - [`QuestTemplate`], the quest header (level gate, title, money/XP reward). [static] //! - [`QuestObjective`], a quest's objective list (today: kill N of a creature). [static] //! - [`QuestRewardItem`] — a quest's guaranteed reward items. [static] -//! - [`CreatureQuest`] , which creature STARTS (`!`) / ENDS (`?`) which quest (many-to-many). [static] +//! - [`CreatureQuest`], which creature STARTS (`!`) / ENDS (`?`) which quest (many-to-many). [static] //! - [`CharacterQuest`], a character's live quest log: one row per accepted quest with per-objective //! progress counts, RLS-scoped to the owner. Born in `accept_quest`. [entity] //! @@ -148,7 +148,7 @@ pub mod quest_role { /// `CMSG_QUESTGIVER_*` op, so this only rejects clearly-out-of-range abuse (mirrors `loot`/vendor range). const QUEST_GIVER_RANGE_SQ: f32 = 100.0; -/// Party quest-SHARE range : reuses the group system's own kill-reward range +/// Party quest-SHARE range: reuses the group system's own kill-reward range /// (`crate::group::GROUP_XP_RANGE_SQ`, vanilla's 74yd `CONFIG_FLOAT_GROUP_XP_DISTANCE`) rather than /// the tight NPC-interaction [`QUEST_GIVER_RANGE_SQ`] (10yd) — sharing a quest is a party-visibility /// action (no walking up to a person), the same range the game already treats as "close enough to @@ -752,7 +752,7 @@ fn giver_has_quest_role( match *giver { GiverKind::Creature(entry) => creature_has_quest_role(ctx, entry, quest_entry, role), GiverKind::GameObject(entry) => gameobject_has_quest_role(ctx, entry, quest_entry, role), - // Item-starts-quest : satisfies START iff the item's OWN template names this + // Item-starts-quest: satisfies START iff the item's OWN template names this // quest — never END (an item never completes a quest). GiverKind::Item(entry) => { role == quest_role::START @@ -763,7 +763,7 @@ fn giver_has_quest_role( .find(entry) .is_some_and(|t| t.start_quest == quest_entry) } - // Party share : satisfies START iff the sharer is CURRENTLY, ACTIVELY on the + // Party share: satisfies START iff the sharer is CURRENTLY, ACTIVELY on the // quest (an un-rewarded, un-failed row) — never END. This is the hard gate against a spoofed // giver_guid self-authorizing: even a genuinely grouped, in-range peer can't hand out a quest // they aren't actually holding right now. @@ -883,7 +883,7 @@ pub(crate) fn request_accept_quest( // Package ask as well. It hands back the row `apply_accept_effects` resets in place, so the // reset-in-place-vs-insert decision costs no second point-scan of the same row. let existing = accept_gates(ctx, &player, &tmpl)?; - // Timed quests : a `limit_time > 0` template stamps a deadline (accept-time + // Timed quests: a `limit_time > 0` template stamps a deadline (accept-time // ctx.timestamp micros + limit_time seconds, as micros); 0 = untimed (the vast majority). let deadline_micros = if tmpl.limit_time > 0 { ctx.timestamp.to_micros_since_unix_epoch() + (tmpl.limit_time as i64) * 1_000_000 @@ -3048,7 +3048,7 @@ pub(crate) fn enter_sessionless_areatrigger( /// instance (`instance::resolve_or_create_instance`: own live binding → party's live instance → /// create; solo allowed; 5-player cap enforced here at trigger time) and teleports into it. Every /// NON-dungeon target keeps the byte-identical instance-0 path (`is_dungeon_map` false → the same -/// `teleport_player(..., 0,...)` call as before), the Deadmines EXIT trigger (map 36 → map 0) +/// `teleport_player(..., 0, ...)` call as before), the Deadmines EXIT trigger (map 36 → map 0) /// rides that arm, correctly landing the leaver back in the open world. pub(crate) fn apply_enter_areatrigger(ctx: &ReducerContext, player_guid: u64, trigger_id: u32) { let teleport_target = ctx @@ -3092,7 +3092,7 @@ fn is_expired(now_micros: i64, deadline_micros: i64, rewarded: bool, failed: boo deadline_micros != 0 && !rewarded && !failed && now_micros >= deadline_micros } -// The timed-quest expiry tick : every `tick_creatures` cadence (0.5s, via the +// The timed-quest expiry tick: every `tick_creatures` cadence (0.5s, via the // `game_tick_pass!` marker — zero core edits needed elsewhere), scan the quest log for rows whose // deadline has passed and fail them (`failed = true`, `deadline_micros` cleared back to 0 — a failed // row's deadline is meaningless past this point). The gateway's `on_quest_update` relay diffs @@ -3394,7 +3394,7 @@ mod tests { assert!(pick_choice_reward(&choices, u32::MAX, 42).is_err()); } - // ---- Timed quests : is_expired boundary tests ---- + // ---- Timed quests: is_expired boundary tests ---- #[test] fn untimed_deadline_never_expires() { diff --git a/module/src/seed.rs b/module/src/seed.rs index bcfdb0981..f2bc9bd20 100644 --- a/module/src/seed.rs +++ b/module/src/seed.rs @@ -1,6 +1,6 @@ //! //! 1. **`seed_production_core`**: realm, server config, the human-warrior start position, the -//! fallback graveyard/graveyard-zone rows , the TEST account + pre-seeded +//! fallback graveyard/graveyard-zone rows, the TEST account + pre-seeded //! character (with its starter spellbook/action-bar kit), and the EventAI on-aggro barks. Every //! fresh database needs this regardless of whether it will ever host a real import. //! 2. **`seed_map0_demo_content`** (the in-body `DECISION` comment has the full @@ -56,7 +56,7 @@ pub fn init(ctx: &ReducerContext) { /// Stratum 1 — the production core every fresh database needs regardless of whether it will ever /// host a real import: realm, server config, the human-warrior start position, the fallback -/// graveyard/graveyard-zone rows , the TEST account + pre-seeded character (with its +/// graveyard/graveyard-zone rows, the TEST account + pre-seeded character (with its /// starter spellbook/action-bar kit), and the EventAI on-aggro barks. fn seed_production_core(ctx: &ReducerContext) { use constants::start_human_warrior as hw; @@ -107,7 +107,7 @@ fn seed_production_core(ctx: &ReducerContext) { display_id: 49, }); - // Graveyard fallback seed : the SAME five Elwynn/Westfall graveyards + // Graveyard fallback seed: the SAME five Elwynn/Westfall graveyards // `world::graveyard`'s hardcoded consts carry, ALSO row-seeded into `game_graveyard` + // `game_graveyard_zone` so a fresh unimported DB and the live `graveyard::resolve_graveyard` // path agree exactly — mirrors the `game_start_position` precedent (init seeds; the importer's @@ -994,7 +994,7 @@ fn seed_spell_registry(ctx: &ReducerContext) { spell(50050, "Test Conjure", 0, 0, 0, 0, 0, 1, 0, false, 0); effect(50050, 0, 0x07, 2, 0, 0, 5349, 8); // E_CREATE_ITEM: 2× item 5349 (Conjured Muffin), self - // Craft RECIPES are no longer seeded : they import from the real Spell.dbc with real + // Craft RECIPES are no longer seeded: they import from the real Spell.dbc with real // reagents (game_spell_reagent) + skill-up bands (game_skill_ability), offered by the real in-box // trainers. The old synthetic recipe spells here (2538 — which was even FABRICATED as "Roasted Boar // Meat" when the real 2538 is "Charred Wolf Meat"; 50071; 50090-50097) are gone. Crafted-item ON-USE @@ -1544,7 +1544,7 @@ fn seed_scheduler_arming(ctx: &ReducerContext) { scheduled_at: ScheduleAt::Interval(TimeDuration::from_micros(EVENT_TTL_MICROS)), }); - // Schedule the instance reaper every 60s, minutes-scale occupancy + // Schedule the instance reaper every 60s, minutes-scale occupancy // stamping + the 30min-empty / reset-requested reap. A live DB (auto-migrate publish) never // re-runs init, so re-arm there via `debug_rearm_instance_reaper` (the // `debug_rearm_creature_tick` precedent). diff --git a/module/src/seed/fixtures.rs b/module/src/seed/fixtures.rs index e8075457e..a04b9084e 100644 --- a/module/src/seed/fixtures.rs +++ b/module/src/seed/fixtures.rs @@ -1601,7 +1601,7 @@ pub(crate) fn seed_scenario_fixtures(ctx: &ReducerContext) { }); } - // --- WEAPON MASTER : "Woo Ping" (51005) sells weapon proficiencies for gold , + // --- WEAPON MASTER: "Woo Ping" (51005) sells weapon proficiencies for gold, // the vanilla weapon-master shape (a trainer-list row whose `learn_skill_line` names a weapon line // instead of a spell/profession). Mirrors the 51004 vendor block: GOSSIP|TRAINER, faction 35 // (FRIENDLY, never a kill target). diff --git a/module/src/skill.rs b/module/src/skill.rs index 41d75cd3a..2594b8f16 100644 --- a/module/src/skill.rs +++ b/module/src/skill.rs @@ -35,7 +35,7 @@ pub const SKILL_PER_LEVEL: u32 = 5; /// read with no remap — a local enum would force a translation layer at the descriptor boundary). Only /// the lines combat/professions can actually produce are defined; ranged lines come with their features. /// -/// DEPRECATION POINTER : `game_skill_line` (`skilldata.rs`), loaded from the operator's +/// DEPRECATION POINTER: `game_skill_line` (`skilldata.rs`), loaded from the operator's /// real `SkillLine.dbc` by the importer, is now the AUTHORITATIVE source for "what skill lines exist" — /// it carries every vanilla line (~135), not just the ~17 hand-picked here. These consts stay because /// the wire protocol is still keyed on the same verbatim ids (no remap needed either way), but treat @@ -981,7 +981,7 @@ const LEARN_AXE_1H_SPELL_ID: u32 = 50130; // -> learn_skill_line = AXE_1H (44), #[cfg(feature = "debug_reducers")] const LEARN_POLEARM_SPELL_ID: u32 = 50131; // -> learn_skill_line = POLEARM (229), required_level 60 (the level-refusal fixture) -/// DRIVE the real trainer-buy branch for a WEAPON PROFICIENCY without the trainer-window +/// DRIVE the real trainer-buy branch for a WEAPON PROFICIENCY without the trainer-window /// UI — the weapon-master twin of `debug_learn_profession_from_trainer` above, same shape: the requested /// `skill_line` (Daggers/1H Axe/Polearm/…) maps to its marker spell id, the nearest live TRAINER creature on /// the learner's map is resolved SERVER-SIDE (mirroring `debug_skin_nearest`'s nearest-search — the @@ -1092,7 +1092,7 @@ pub fn debug_learn_riding_from_trainer( /// crossbow fall to UNARMED in `weapon_subclass_to_skill_line` and are therefore NOT listed here). /// Returns an empty slice for unknown classes (e.g. creatures). /// -/// DEPRECATION POINTER : this hand-authored per-class table could in principle be +/// DEPRECATION POINTER: this hand-authored per-class table could in principle be /// derived from `game_skill_availability` (`SkillRaceClassInfo.dbc`'s `class_mask`, imported by /// `skilldata.rs`) joined against `skill_line::*`'s known combat lines — that data-driven replacement /// is a follow-up, not done here (this function's behavior is UNCHANGED by this item). @@ -1529,7 +1529,7 @@ mod tests { assert_eq!(skill_diff(300, 315), 0); // higher weapon skill floors at 0 } - /// The combat/profession taxonomy `apply_trainer_buy` forks the weapon-master branch on : + /// The combat/profession taxonomy `apply_trainer_buy` forks the weapon-master branch on: /// a weapon line (DAGGER) is combat (level-capped); a profession line (COOKING) is not (static-tier capped). #[test] fn is_combat_skill_line_true_for_dagger_false_for_cooking() { diff --git a/module/src/spell/cast/targeting.rs b/module/src/spell/cast/targeting.rs index 33fe533f1..eac754010 100644 --- a/module/src/spell/cast/targeting.rs +++ b/module/src/spell/cast/targeting.rs @@ -519,7 +519,7 @@ pub(crate) fn aura_apply( a.proc_icd_ms = proc.icd_ms; auras.id().update(a); } else { - // Stacking-group conflict : resolve BEFORE picking a slot, so a Refuse never + // Stacking-group conflict: resolve BEFORE picking a slot, so a Refuse never // consumes a slot and an Apply's evictions free slots for this fresh insert. A same-spell recast // never reaches this branch (caught by the `existing`-by-effect_id match above) — matches "same- // spell-same-caster refresh keeps the existing precedence rule, runs BEFORE group logic". No-op @@ -1341,7 +1341,7 @@ fn apply_inventory_effect( // life is rejected (the `pickpocketed` marker), so the same spawn can't be drained twice. Money // is the creature's TEMPLATE range (`money_min`/`money_max` — the SAME source the kill path // uses, reused via `roll_money`), falling back to the level heuristic for un-imported creatures - // (`money_max == 0`). NOT rank-scaled (vanilla pickpocket isn't). Item rows : + // (`money_max == 0`). NOT rank-scaled (vanilla pickpocket isn't). Item rows: // `roll_pickpocket_loot` rolls the creature's `game_pickpocket_loot` table into // `game_corpse_loot` keyed on the TARGET's (still-alive) guid, inside this SAME once-gate, so // items can never be drained twice per life either. Never on players (no pockets / not a diff --git a/module/src/spell/control.rs b/module/src/spell/control.rs index f502f539f..9f92780a6 100644 --- a/module/src/spell/control.rs +++ b/module/src/spell/control.rs @@ -380,7 +380,7 @@ pub(crate) fn break_auras_on_damage( // break (landing a swing/cast reveals the ATTACKER) is a separate call at each call site; this is // the VICTIM-side half. No-op on a non-stealthed target (the common path). super::break_stealth(ctx, target_guid); - // Cast PUSHBACK-on-damage : DIRECT damage (melee swing, direct spell) PUSHES BACK the + // Cast PUSHBACK-on-damage: DIRECT damage (melee swing, direct spell) PUSHES BACK the // victim's in-progress TIMED cast (the cast bar slides `CAST_PUSHBACK_MS`, capped at // `CAST_PUSHBACK_MAX` pushbacks) — vanilla 1.12 does NOT cancel a regular cast on damage; only // Kick/Counterspell-style interrupts (`interrupt_cast_and_lock`) and CC (`interrupt_cast` via @@ -830,7 +830,7 @@ mod tests { } } - /// Cast pushback : the per-hit slide is cmangos's fixed 500ms, and the cap is 2 + /// Cast pushback: the per-hit slide is cmangos's fixed 500ms, and the cap is 2 /// pushbacks per cast (the vanilla cap) — so a 3rd+ direct hit is a documented no-op. #[test] fn pushback_constants_match_the_cmangos_convention() { diff --git a/module/src/spell/effects.rs b/module/src/spell/effects.rs index 2a62d82e6..bc443d880 100644 --- a/module/src/spell/effects.rs +++ b/module/src/spell/effects.rs @@ -20,7 +20,7 @@ use crate::game_world_entity; use crate::game_melee_attack; // The taxonomy consts, the `Aura` struct + the generated `game_aura`/`game_spell` accessor traits, and // every pure helper this file calls (`should_arm_spell_retaliation`, `dispel_category_matches`, -// `rederive_pool`, `absorb_draw`, `drain_shields`, `stat_bonus`,...) are re-exported by `mod.rs`. +// `rederive_pool`, `absorb_draw`, `drain_shields`, `stat_bonus`, ...) are re-exported by `mod.rs`. use super::*; /// Should a PLAYER's spell hit arm the target creature's reciprocal melee retaliation? True only when the diff --git a/module/src/spell/scheduler.rs b/module/src/spell/scheduler.rs index fba9ce487..3be2ba845 100644 --- a/module/src/spell/scheduler.rs +++ b/module/src/spell/scheduler.rs @@ -642,7 +642,7 @@ pub fn tick_auras(ctx: &ReducerContext, _schedule: AuraSchedule) { .filter(..=now) .filter(|a| is_due_for_expiry(a.eff_kind, a.expires_at, now)) .collect(); - // Evidence : the index scan's candidate count vs the table's total row count. + // Evidence: the index scan's candidate count vs the table's total row count. // `Table::count()` reads datastore metadata (O(1) — no scan), so logging it costs nothing extra; the // gap between the two numbers is the rows the OLD full-scan touched that this pass no longer does. // `log::debug!` (not `info!`) so a normal RUST_LOG=info run stays silent; enable `RUST_LOG=debug` (or @@ -674,7 +674,7 @@ pub fn tick_auras(ctx: &ReducerContext, _schedule: AuraSchedule) { remount.push(a.target_guid); } } - // CC diminishing returns : a NATURAL EXPIRY is one of the two REMOVAL events that starts + // CC diminishing returns: a NATURAL EXPIRY is one of the two REMOVAL events that starts // the 15s DR window (the other is a dispel — `effects::dispel_target`). `dr_category_for_effect` is a // no-op for a non-CC aura / a creature target, so this only touches the handful of player CC rows. // Runs BEFORE the delete loop below (order doesn't matter — `game_dr_state` is a separate table — but diff --git a/module/src/spell/tables.rs b/module/src/spell/tables.rs index be64a8a42..b8912ac5a 100644 --- a/module/src/spell/tables.rs +++ b/module/src/spell/tables.rs @@ -171,7 +171,7 @@ pub struct SpellProcEvent { /// columns are the **frozen typed snapshot** (computed once at apply) so periodic ticks + combat /// stat-reads are self-contained — NO template/effect re-join on the hot path. [event] /// -/// `by_expiry` is a plain INDEX ADD over the already-existing `expires_at` column, no +/// `by_expiry` is a plain INDEX ADD over the already-existing `expires_at` column, no /// new column, no default, no data migration; a schema/metadata-only change on this gateway-SUBSCRIBED /// table (verified by `gateway/tests/schema_parity.rs`, which checks columns/bindings, not indexes). /// `scheduler::tick_auras`'s expiry pass range-scans `by_expiry().filter(..=now)` instead of `.iter()`ing @@ -382,7 +382,7 @@ pub struct SpellCastEvent { // cast each spell once). END-appended + #[default(0u32)] → additive auto-migration. #[default(0u32)] pub cooldown_ms: u32, - // PUSHBACK payload : the number of milliseconds added to the in-progress timed + // PUSHBACK payload: the number of milliseconds added to the in-progress timed // cast. The Gateway relays SMSG_SPELL_DELAYED{guid, delay_time}. Kind names the signal; this // value remains zero on every other kind and preserves decoding for old rows. // END-appended + #[default(0u32)] → additive auto-migration (the publish-migration rule). @@ -401,7 +401,7 @@ pub struct SpellCastEvent { #[default(false)] pub is_proc_log: bool, // The melee swing outcome an on-next-swing FIRE row rode (114): CombatEvent hit_info codes - // (0 normal, 1 crit, 2 miss, 3 dodge, 4 parry,...). The relay shapes the SMSG_SPELL_GO miss + // (0 normal, 1 crit, 2 miss, 3 dodge, 4 parry, ...). The relay shapes the SMSG_SPELL_GO miss // list from it when damage == 0 — the client then prints the yellow "Your Heroic Strike // missed/was dodged/was parried" line instead of a white MISS. 0 (normal) on every other row. // Binding hand-synced. END-appended + #[default(0u8)] -> additive auto-migration. diff --git a/module/src/spell/tests.rs b/module/src/spell/tests.rs index 05672c72f..a749a73cf 100644 --- a/module/src/spell/tests.rs +++ b/module/src/spell/tests.rs @@ -712,7 +712,7 @@ fn break_on_damage_flag_decode() { assert!(!breaks_on_damage(0x2)); // a different aura_interrupt bit (not BREAK_ON_DAMAGE) → not broken } -// --- Aura-expiry reap gate -------------------------------------------------------- +// --- Aura-expiry reap gate -------------------------------------------------------- // `tick_auras`'s expiry pass now range-scans `game_aura.by_expiry()` (a btree index on `expires_at`) to // the horizon instead of `.iter()`ing the whole table, then applies `is_due_for_expiry` as the exact same // combined predicate the old full scan used inline (`a.expires_at <= now && a.eff_kind != A_STEALTH`). diff --git a/module/src/trainer.rs b/module/src/trainer.rs index 75332c607..8741c7c1d 100644 --- a/module/src/trainer.rs +++ b/module/src/trainer.rs @@ -506,7 +506,7 @@ pub(crate) fn apply_trainer_buy( // idempotent already-known no-op), and on Ok it grants the skill at 1/75. The `learn_skill_line == 0` // arm below is the EXISTING spell path verbatim (byte-identical → no class-spell regression). // - // WEAPON-MASTER FORK : the SAME `learn_skill_line` column shape also carries weapon + // WEAPON-MASTER FORK: the SAME `learn_skill_line` column shape also carries weapon // proficiency offerings (Daggers, Polearm, …) — no new row kind. `is_combat_skill_line` tells the two // apart and the cap/known computation diverges for each: // - PROFESSION cap is the offering's STATIC tier column (`learn_skill_cap`: Apprentice 75, Journeyman @@ -727,7 +727,7 @@ mod tests { ); } - /// WEAPON-LEARN PRESENCE-KNOWN semantics : unlike a profession offering (known = a CAP + /// WEAPON-LEARN PRESENCE-KNOWN semantics: unlike a profession offering (known = a CAP /// comparison), a weapon offering's "known" is mere ROW PRESENCE — feeding `trainer_buy_check` a bare /// `known` bool either way, so the SAME gate enforces both: a class-seeded weapon line (row present, /// regardless of its stored cap) refuses as already-known; a lacked line (no row at all) proceeds. diff --git a/module/src/transfer/tests.rs b/module/src/transfer/tests.rs index 676c0ba3d..abd3dc553 100644 --- a/module/src/transfer/tests.rs +++ b/module/src/transfer/tests.rs @@ -950,14 +950,14 @@ fn folding_a_money_delta_adds_it_to_the_escrowed_blob() { // CROSS-DATABASE: the transport ratchet, and the six-step crash matrix // ------------------------------------------------------------------------------------- -/// THE RATCHET. A character-owned table with no `character_owned!(transfer,..)` arm does not +/// THE RATCHET. A character-owned table with no `character_owned!(transfer, ..)` arm does not /// cross a database boundary — and unlike a missing delete sweep (which leaks rows, loudly, /// forever) that failure is INVISIBLE: the character simply arrives without that table's data, /// and the source copy it came from has already been cascade-deleted. There is no second chance /// and no error anywhere. So: every manifest table must have an arm, and a NEW character-owned /// table fails this test by name in the same edit that adds it. /// -/// "Not transported" is a legal answer, via the `character_owned!(not_transported,..)` marker +/// "Not transported" is a legal answer, via the `character_owned!(not_transported, ..)` marker /// kind, written AT the table (see `rest.rs` / `group.rs`'s invite row) — because a decision /// recorded at the table is a different thing from an omission nobody noticed. /// diff --git a/module/src/transfer/transport.rs b/module/src/transfer/transport.rs index a5463c782..0d9f8763b 100644 --- a/module/src/transfer/transport.rs +++ b/module/src/transfer/transport.rs @@ -2,7 +2,7 @@ //! //! `mod.rs` owns the escrow protocol — when a character may move and what each step is allowed to //! do. This file owns the cargo: the manifest that says WHICH tables travel, the [`RowIo`] direction -//! marker and [`move_rows`] shim every `character_owned!(transfer,..)` arm flows through, the bsatn +//! marker and [`move_rows`] shim every `character_owned!(transfer, ..)` arm flows through, the bsatn //! codec underneath it, and [`ExportBlob`], the one value that crosses the wire. //! //! Everything here is `ReducerContext`-generic or `ReducerContext`-free, which is what lets @@ -142,7 +142,7 @@ pub(crate) fn admit_command_issuer_import( // Cross-database row transport // =========================================================================================== -/// The direction a `character_owned!(transfer,..)` arm is running in. ONE body serves both, so a +/// The direction a `character_owned!(transfer, ..)` arm is running in. ONE body serves both, so a /// table cannot ship rows it does not know how to receive (the drift that would silently drop a /// table's data at the destination). pub enum RowIo<'a> { @@ -242,7 +242,7 @@ pub(crate) fn not_transported(io: &mut RowIo<'_>) { } } -/// One entry of a transport registry: a table name and the `character_owned!(transfer,..)` arm +/// One entry of a transport registry: a table name and the `character_owned!(transfer, ..)` arm /// that moves its rows. `crate::CHARACTER_OWNED_TRANSFERS` is `&[TransportArm]`; /// the harness supplies its own slice over a fake context. pub(crate) type TransportArm<'a, C> = (&'a str, fn(&C, u64, &mut RowIo<'_>)); @@ -445,7 +445,7 @@ pub(crate) fn import_rows( /// The manifest is the load-bearing SCHEMA half: the destination compares it against its OWN build /// (`manifest()`) and refuses an import from a shard whose character-owned table set differs. The /// `payload` alongside it is the DATA half — the actual rows, one entry per manifest -/// table, produced by that table's `character_owned!(transfer,..)` arm. +/// table, produced by that table's `character_owned!(transfer, ..)` arm. /// /// **Nothing that is already inside `character_row` gets a second field here.** Until then /// the blob ALSO carried `name`/`level`/`map_id`/`instance_id`/`x`/`y`/`z`/`o`/`health`/`power` as diff --git a/module/src/tripwires.rs b/module/src/tripwires.rs index 1bc62bc21..17e6c244b 100644 --- a/module/src/tripwires.rs +++ b/module/src/tripwires.rs @@ -1012,8 +1012,8 @@ mod partition_discipline_tripwire { const SHARD_ID_OWNERS: &[&str] = &["module/src/region.rs", "module/src/load.rs"]; /// The ways module code could reach a shard id: the assignment table's accessor, the `.shard` - /// column itself (`row.shard`), and the ROW TYPE, because `let RegionAssignment { shard,.. } - /// = row;` and `match row { RegionAssignment { shard: db,.. } => … }` bind the column without + /// column itself (`row.shard`), and the ROW TYPE, because `let RegionAssignment { shard, .. } + /// = row;` and `match row { RegionAssignment { shard: db, .. } => … }` bind the column without /// ever writing a dot (both forms were confirmed to slip past the first two). You cannot /// destructure a type you may not name. `game_character_shard` / `CharacterShard` deliberately /// do NOT count — that table stores a `(map_id, instance_id)` LOCATION precisely so nothing has @@ -1586,7 +1586,7 @@ pub(crate) mod grid_cell_tripwire { /// only if it looks like a type name (starts uppercase, matching this codebase's convention) — /// `TypeName {.. }` or `TypeName {..prev }`, so `None` correctly falls out for a shorthand /// `grid_x,` that is really an ordinary function-CALL argument (`queue_motion(.., grid_x, - /// grid_y,..)`: its nearest enclosing `{` is a `fn`/`if`/`match` body brace, not preceded by a + /// grid_y, ..)`: its nearest enclosing `{` is a `fn`/`if`/`match` body brace, not preceded by a /// type name) or any other bare code block. A literal `Self {.. }` constructor resolves through /// [`resolve_self_type`] to the `impl` block's real type name instead of returning `"Self"` /// verbatim — `struct_has_cell_field` searches for `struct Self`, which never exists. diff --git a/module/src/world.rs b/module/src/world.rs index 84b5fd7e2..2544da19d 100644 --- a/module/src/world.rs +++ b/module/src/world.rs @@ -8,7 +8,7 @@ use lyracore_shared::spatial; use spacetimedb::{reducer, table, Identity, ReducerContext, Table}; use crate::faction::game_faction_template; -// Graveyard resolution lives in `graveyard.rs` (extraction), this +// Graveyard resolution lives in `graveyard.rs` (extraction), this // alias keeps every `graveyard::...` call site below byte-identical. use crate::graveyard; use crate::helpers::entity_by_owner; @@ -1049,7 +1049,7 @@ pub(crate) fn apply_player_login( // A relog comes back ALIVE (we don't persist ghost state across a REAL logout), so clear any // leftover corpse — else it orphans (rendered with no owning ghost, with a stale reclaim marker // that MSG_CORPSE_QUERY keeps offering for a now-alive player). Idempotent (no-op if none). - // EXCEPTION : `pending_ghost` means this world entry is the rebuild half of a + // EXCEPTION: `pending_ghost` means this world entry is the rebuild half of a // released ghost's despawn (a cross-map graveyard release, or a reconnect that raced the ghost's // corpse run) — the corpse IS the ghost's reclaim target and MUST survive the rebuild, or a // Deadmines death would silently resurrect corpseless at the Westfall graveyard. @@ -1797,7 +1797,7 @@ pub(crate) fn do_repop(ctx: &ReducerContext, guid: u64) -> Result<(), String> { // The cycle's pet phase ALSO despawns a pet whose owner is dead, so this is belt-and-suspenders (prompt; immediate). crate::creatures::despawn_pets(ctx, player_guid); - // Teleport the ghost to the graveyard `graveyard::resolve_graveyard` resolves for + // Teleport the ghost to the graveyard `graveyard::resolve_graveyard` resolves for // this death: prefer a zone-linked graveyard (imported `game_graveyard`/`game_graveyard_zone` — // cmangos WorldSafeLocs + game_graveyard_zone), falling back to the nearest of every imported // graveyard on the map, falling back to the five hardcoded Elwynn/Westfall consts @@ -2025,7 +2025,7 @@ pub(crate) fn persist_entity(ctx: &ReducerContext, entity: &WorldEntity, set_off // logout/disconnect (`set_offline`) — the established "relog comes back alive" rule, whose // corpse delete lives in `remove_from_world` right after this persist. c.pending_ghost = persisted_pending_ghost(entity.dead, entity.player_flags, set_offline); - // GM playtest state survives an entity despawn the same way, and for the same + // GM playtest state survives an entity despawn the same way, and for the same // reason: a cross-map `.tele` (or a cross-database shard hop, whose `begin_transfer` freeze // calls this with `set_offline: false` too) persists-then-deletes the entity, and without this // stamp `build_player_entity` rebuilds it with `.god`/`.speed` silently off. Cleared on a real diff --git a/module/src/xp.rs b/module/src/xp.rs index cf33a0617..841bbd47e 100644 --- a/module/src/xp.rs +++ b/module/src/xp.rs @@ -394,7 +394,7 @@ pub(crate) fn grant_xp(ctx: &ReducerContext, p: &mut WorldEntity, amount: u32) { if amount == 0 { return; } - // GM playtest `.xprate` : a basis-points multiplier (10000 = 1×) ON TOP of the + // GM playtest `.xprate`: a basis-points multiplier (10000 = 1×) ON TOP of the // realm `xp_rate` (`rated_xp`, already folded in by the caller at each SOURCE — kill XP in // `award_xp`, quest XP in `quest.rs`). Applied HERE, the one chokepoint both sources share, so a // single multiply can never drift between them. u64 math to avoid a u32*u32 overflow. Missing From d22d08a0a6220cfd631028def9d8031e3042ffe5 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 15:49:28 +0200 Subject: [PATCH 17/21] docs: repair comment sweep damage in Rust sources Restore sentences the sweep broke, drop leftover work-item numbers and slice, stage and phase labels, and state history notes as present-tense invariants. Co-Authored-By: Claude Sonnet 5.5 --- crates/lyracore-shared/src/constants.rs | 4 +- crates/lyracore-shared/src/quest.rs | 2 +- crates/lyracore-shared/src/spatial.rs | 6 +- crates/lyracore-shared/src/vmap.rs | 6 +- gateway/src/codec/addon.rs | 2 +- gateway/src/codec/char.rs | 2 +- gateway/src/codec/corpse.rs | 6 +- gateway/src/codec/entity.rs | 18 ++-- gateway/src/codec/item.rs | 9 +- gateway/src/codec/loot.rs | 8 +- gateway/src/codec/mod.rs | 4 +- gateway/src/codec/tests.rs | 14 +-- gateway/src/codec/update_mask.rs | 1 + gateway/src/codec/values.rs | 4 +- gateway/src/realm_core.rs | 2 +- gateway/src/stdb/connection.rs | 4 +- gateway/src/stdb/reads/account.rs | 2 +- gateway/src/stdb/reads/items.rs | 6 +- gateway/src/stdb/reads/templates.rs | 2 +- gateway/src/stdb/reducers.rs | 10 +- gateway/src/stdb/subscriptions.rs | 8 +- gateway/src/stdb/views.rs | 4 +- gateway/src/world/handlers/char.rs | 10 +- gateway/src/world/handlers/loot.rs | 6 +- gateway/src/world/handlers/query.rs | 2 +- gateway/src/world/mod.rs | 2 +- gateway/src/world/party_tests.rs | 2 +- gateway/src/world/store.rs | 8 +- gateway/src/world/tests.rs | 2 +- gateway/tests/schema_parity.rs | 4 +- importer/src/dbc.rs | 20 ++-- importer/src/main.rs | 121 ++++++++++++------------ importer/src/spell.rs | 22 ++--- importer/src/terrain.rs | 8 +- module/src/character.rs | 5 +- module/src/chat.rs | 4 +- module/src/combat/engage.rs | 2 +- module/src/combat/swing.rs | 2 +- module/src/corpse.rs | 2 +- module/src/creatures/ai.rs | 8 +- module/src/creatures/spawn.rs | 10 +- module/src/creatures/tick/lifecycle.rs | 19 ++-- module/src/creatures/tick/mod.rs | 29 +++--- module/src/debug/encounter.rs | 2 +- module/src/debug/instance.rs | 4 +- module/src/debug/mod.rs | 37 ++++---- module/src/encounter.rs | 16 ++-- module/src/exploration.rs | 3 +- module/src/gameobject.rs | 34 ++++--- module/src/gc.rs | 14 +-- module/src/gm.rs | 6 +- module/src/graveyard.rs | 6 +- module/src/group.rs | 10 +- module/src/gw.rs | 6 +- module/src/helpers.rs | 4 +- module/src/hooks.rs | 6 +- module/src/instance.rs | 10 +- module/src/items/tables.rs | 14 +-- module/src/lib.rs | 10 +- module/src/loot/mod.rs | 32 +++---- module/src/loot/rolls.rs | 8 +- module/src/motion.rs | 2 +- module/src/operations.rs | 2 +- module/src/professions.rs | 8 +- module/src/quest.rs | 18 ++-- module/src/reputation.rs | 2 +- module/src/seed.rs | 30 +++--- module/src/seed/fixtures.rs | 8 +- module/src/spell/cast/resolve.rs | 8 +- module/src/spell/cast/targeting.rs | 16 ++-- module/src/spell/control.rs | 2 +- module/src/spell/scheduler.rs | 6 +- module/src/spell/tables.rs | 16 ++-- module/src/spell/taxonomy.rs | 4 +- module/src/talent.rs | 4 +- module/src/terrain.rs | 7 +- module/src/trainer.rs | 10 +- module/src/transfer/mod.rs | 4 +- module/src/tripwires.rs | 85 +++++++---------- module/src/world.rs | 32 +++---- 80 files changed, 434 insertions(+), 464 deletions(-) diff --git a/crates/lyracore-shared/src/constants.rs b/crates/lyracore-shared/src/constants.rs index 7284e3e4b..6dc1b82ea 100644 --- a/crates/lyracore-shared/src/constants.rs +++ b/crates/lyracore-shared/src/constants.rs @@ -447,7 +447,7 @@ mod tests { assert_eq!(npc_flags::AUCTIONEER, 0x1000); } - /// pin the shared GameObject QUESTGIVER type id against cmangos, this is the + /// Pins the shared GameObject QUESTGIVER type id against cmangos. This is the /// SINGLE source both `module/src/gameobject.rs::go_type::QUESTGIVER` and the gateway's /// `CMSG_GAMEOBJ_USE` dispatch read, so a silent edit here would desync both sides at once. #[test] @@ -461,7 +461,7 @@ mod tests { assert_eq!(go_type::MEETINGSTONE, 23); } - /// the gossip option action codes the dispatcher matches on must be pairwise + /// The gossip option action codes the dispatcher matches on must be pairwise /// distinct — a collision here would silently misroute one action to another's handler. #[test] fn gossip_option_actions_are_distinct() { diff --git a/crates/lyracore-shared/src/quest.rs b/crates/lyracore-shared/src/quest.rs index 28ad6647b..aa1d37d13 100644 --- a/crates/lyracore-shared/src/quest.rs +++ b/crates/lyracore-shared/src/quest.rs @@ -223,7 +223,7 @@ mod tests { assert!(!class_allowed(u32::MAX, u8::MAX)); } - // ---- Level-cap payout(e)) ---- + // ---- Level-cap payout ---- #[test] fn max_level_money_reward_uses_the_documented_v_coefficient() { diff --git a/crates/lyracore-shared/src/spatial.rs b/crates/lyracore-shared/src/spatial.rs index e3d259a49..b856e8a77 100644 --- a/crates/lyracore-shared/src/spatial.rs +++ b/crates/lyracore-shared/src/spatial.rs @@ -48,7 +48,7 @@ pub fn grid_cell(x: f32, y: f32) -> (i32, i32) { /// index-served at all — `IndexProbe::Range` is documented in that release as "we currently never /// construct this variant" — and a disjunction (`OR`) is evaluated row-by-row, never turned into /// multiple probes. So the 4-column `by_grid` index `(map_id, instance_id, grid_x, grid_y)` is -/// *unusable* from SQL, and a `grid_x BETWEEN.. AND grid_y BETWEEN..` box is a residual filter over +/// *unusable* from SQL, and a `grid_x BETWEEN .. AND grid_y BETWEEN ..` box is a residual filter over /// a full scan no matter how the columns are ordered. Folding the two grid columns into one turns the /// only shape the planner CAN serve — a 3-column all-equality probe on /// `(map_id, instance_id, cell)` — into an exact match for a single grid cell. @@ -148,7 +148,7 @@ impl GridBox { /// Inclusive grid-cell bounds of the box (anchor ± [`BOX_HALF_SPAN`]): `(gx_min, gx_max, gy_min, /// gy_max)` — the WHERE-clause range the AOI subscription uses (`grid_x BETWEEN gx_min AND gx_max AND - /// grid_y BETWEEN...`). The SAME range drives the module's movement-recipient selection, so the + /// grid_y BETWEEN ...`). The SAME range drives the module's movement-recipient selection, so the /// gateway's spawn set and the peer-movement relay stay aligned. pub fn bounds(&self) -> (i32, i32, i32, i32) { ( @@ -306,7 +306,7 @@ mod tests { ); } - /// the packed `cell` id must be a BIJECTION over `(i32, i32)`. Every writer of `grid_x`/ + /// The packed `cell` id must be a BIJECTION over `(i32, i32)`. Every writer of `grid_x`/ /// `grid_y` also writes this value and the AOI subscription probes it with an equality, so a /// collision would silently merge two cells — two players 500yd apart would see each other, and /// the entities of one cell would be delivered to the wrong box. Round-tripping is the proof: diff --git a/crates/lyracore-shared/src/vmap.rs b/crates/lyracore-shared/src/vmap.rs index cd9c5b145..4e8a20a8f 100644 --- a/crates/lyracore-shared/src/vmap.rs +++ b/crates/lyracore-shared/src/vmap.rs @@ -23,7 +23,7 @@ pub struct VmapTri { /// Bytes per packed triangle: 1 (class tag) + 4 (group_id) + 4 (mogp_flags) + 36 (9 × f32 verts). /// `pub` so the importer can size a per-blob shard cap. A dense cell's triangle count -/// isn't bounded, but a single `spacetime call` argument is — see `importer/src/vmap.rs`). +/// isn't bounded, but a single `spacetime call` argument is (see `importer/src/vmap.rs`). pub const TRI_BYTES: usize = 1 + 4 + 4 + 36; /// Header: 1 version byte + 4-byte LE triangle count. `pub` for the same reason as `TRI_BYTES`. pub const HEADER_BYTES: usize = 5; @@ -529,7 +529,7 @@ mod tests { } // ------------------------------------------------------------------------------------- - // Ray-query tests, a synthetic stand-in for the done-when's live scenarios: a + // Ray-query tests, a synthetic stand-in for live scenarios: a // vertical "column" wall (abbey pillar analogue), a clear segment, and a doodad that // blocks the collision ray but not the LoS ray. // ------------------------------------------------------------------------------------- @@ -678,7 +678,7 @@ mod tests { } // ------------------------------------------------------------------------------------- - // Area-info tests, a synthetic stand-in for the done-when's live scenarios: a + // Area-info tests, a synthetic stand-in for live scenarios: a // horizontal floor quad tagged indoor (abbey-interior analogue) under a probe reports the // group + indoor=true; a probe with no WMO geometry in range reports "outdoors" (`None`). // ------------------------------------------------------------------------------------- diff --git a/gateway/src/codec/addon.rs b/gateway/src/codec/addon.rs index 6624356ff..d6928708a 100644 --- a/gateway/src/codec/addon.rs +++ b/gateway/src/codec/addon.rs @@ -197,7 +197,7 @@ mod tests { ); } - /// the client's `SendAddonMessage` API distributes over PARTY, RAID, GUILD and + /// The client's `SendAddonMessage` API distributes over PARTY, RAID, GUILD and /// OFFICER only. RAID_LEADER and RAID_WARNING chat lines cannot carry addon traffic at all, /// and WHISPER/CHANNEL addon frames are this bridge's own private channels, not shared wire. #[test] diff --git a/gateway/src/codec/char.rs b/gateway/src/codec/char.rs index c41f98df7..18501d75a 100644 --- a/gateway/src/codec/char.rs +++ b/gateway/src/codec/char.rs @@ -39,7 +39,7 @@ pub struct CharacterView { pub guild_id: u32, } -/// Build the `SMSG_CHAR_ENUM` reply for the character-select screen, gateway +/// Build the `SMSG_CHAR_ENUM` reply for the character-select screen (gateway /// translation §4). Each [`CharacterView`] becomes a `wow_world_messages` `Character` block; /// The five appearance bytes a player picks at character creation, bundled so `create_character` /// (dispatch → trait → coordinator) passes one value instead of five positional `u8`s that are diff --git a/gateway/src/codec/corpse.rs b/gateway/src/codec/corpse.rs index 092fba75e..4b83ad93c 100644 --- a/gateway/src/codec/corpse.rs +++ b/gateway/src/codec/corpse.rs @@ -1,4 +1,4 @@ -//! Corpse wire mapping: the `MSG_CORPSE_QUERY` reply + the corpse CREATE_OBJECT (slice 5), plus the +//! Corpse wire mapping: the `MSG_CORPSE_QUERY` reply + the corpse CREATE_OBJECT, plus the //! Resurrection accept-prompt. use super::*; @@ -20,7 +20,7 @@ pub struct CorpseView { pub is_bones: bool, } -/// Build the `MSG_CORPSE_QUERY` reply (slice 5): tells the client where the player's corpse is so it +/// Build the `MSG_CORPSE_QUERY` reply: tells the client where the player's corpse is so it /// draws the map marker and offers "Reclaim Corpse" when the ghost is near it. `None` → `NotFound`. /// Same map for `map` (ghost map) and `corpse_map` — we have no cross-map corpses (single zone). pub fn build_corpse_query_response( @@ -39,7 +39,7 @@ pub fn build_corpse_query_response( } } -/// Build the CREATE_OBJECT for a player corpse (slice 5): a CORPSE-type object with a stationary +/// Build the CREATE_OBJECT for a player corpse: a CORPSE-type object with a stationary /// position (`HasPosition`, UPDATEFLAG 0x40) and the `UpdateCorpse` descriptors (owner, position, /// display, bytes) so the client renders the dead body. Relayed on `game_corpse` insert; the matching /// `SMSG_DESTROY_OBJECT` on delete (reclaim/decay) reuses `build_destroy_object`. diff --git a/gateway/src/codec/entity.rs b/gateway/src/codec/entity.rs index 34e9843fb..743699a27 100644 --- a/gateway/src/codec/entity.rs +++ b/gateway/src/codec/entity.rs @@ -59,8 +59,8 @@ pub struct EntityView { pub player_flags: u32, pub xp: u32, pub next_level_xp: u32, - pub money: u32, // PLAYER_FIELD_COINAGE for a player; corpse loot for a creature (slice 3) - pub unit_bytes_1: u32, // UNIT_FIELD_BYTES_1; byte 3 carries UNIT_VIS_FLAG_GHOST while a ghost (slice 5) + pub money: u32, // PLAYER_FIELD_COINAGE for a player; corpse loot for a creature + pub unit_bytes_1: u32, // UNIT_FIELD_BYTES_1; byte 3 carries UNIT_VIS_FLAG_GHOST while a ghost /// `UNIT_FIELD_BYTES_2`; byte 0 is the sheath state (0 stowed / 1 melee drawn / 2 ranged drawn). /// Without it in the CREATE, everyone entering AOI range renders this unit permanently unarmed /// no matter what it is holding. @@ -113,7 +113,7 @@ pub enum CreateKind { } /// Map an equipment-slot ordinal (0..=18) to its `VisibleItemIndex` — the `PLAYER_VISIBLE_ITEM[slot]` -/// descriptor that renders gear on the 3D character model (slice-2). `None` for a non-equipment slot +/// descriptor that renders gear on the 3D character model. `None` for a non-equipment slot /// (equipped bags 19..22, backpack 23..38, bank, …): those carry an inventory-slot guid but nothing /// model-visible. The index is 0-based and matches the equipment-slot ordinal 1:1 (15 = MAINHAND). pub(crate) fn visible_item_index(slot: u8) -> Option { @@ -321,7 +321,7 @@ pub fn build_create_object( let (skin, face, hair_style, hair_color) = unpack4(entity.player_bytes); let (facial_hair, pb2_b, pb2_c, pb2_d) = unpack4(entity.player_bytes_2); let (pb3_a, pb3_b, pb3_c, pb3_d) = unpack4(entity.player_bytes_3); - let (ub1_a, ub1_b, ub1_c, ub1_d) = unpack4(entity.unit_bytes_1); // ghost vis bit in byte 3 (slice 5) + let (ub1_a, ub1_b, ub1_c, ub1_d) = unpack4(entity.unit_bytes_1); // ghost vis bit in byte 3 let (ub2_a, ub2_b, ub2_c, ub2_d) = unpack4(entity.unit_bytes_2); // sheath state in byte 0 // Melee and ranged paperdoll values are stored Module projections. CREATE reads them @@ -339,7 +339,7 @@ pub fn build_create_object( .set_unit_nativedisplayid(entity.native_display_id as i32) .set_unit_flags(entity.unit_flags as i32) .set_unit_baseattacktime(entity.base_attack_time_ms as i32) - .set_unit_bytes_1(ub1_a, ub1_b, ub1_c, ub1_d) // ghost render bit (slice 5) + .set_unit_bytes_1(ub1_a, ub1_b, ub1_c, ub1_d) // ghost render bit // gtker NAMES this setter's params after PLAYER_BYTES_2 (facial hair/rest state) — wrong // field, right index (164). Byte 0 is the sheath state; the names are noise. .set_unit_bytes_2(ub2_a, ub2_b, ub2_c, ub2_d) @@ -349,9 +349,9 @@ pub fn build_create_object( .set_player_flags(entity.player_flags as i32) .set_player_xp(entity.xp as i32) .set_player_next_level_xp(entity.next_level_xp as i32) - .set_player_field_coinage(entity.money as i32) // purse in copper (slice 3) + .set_player_field_coinage(entity.money as i32) // purse in copper // Free talent points (PLAYER_CHARACTER_POINTS1) so the talent pane shows a non-zero count - // (rank 27). Mirrors the module's `talent::talent_points_available`: 1/level from level 10 + // Mirrors the module's `talent::talent_points_available`: 1/level from level 10 // (= level - 9), clamped at 0 below 10. NOTE: this does NOT yet subtract points already spent // (the gateway doesn't read game_character_talent here) — it's a display of points EARNED, exact // for a fresh character and approximate after spending. Learning is reducer-gated server-side; @@ -503,7 +503,7 @@ pub fn build_create_object( // Sheath state, a creature draws its weapon on engage exactly like a player, so // this belongs on the Unit branch too, not just the player one. .set_unit_bytes_2(cb2_a, cb2_b, cb2_c, cb2_d) - .set_unit_dynamic_flags(entity.dynamic_flags as i32) // slice 2: corpse/lootable bits + .set_unit_dynamic_flags(entity.dynamic_flags as i32) // corpse/lootable bits .set_unit_npc_flags(entity.npc_flags as i32); // gossip/vendor/questgiver/trainer icons (item 6) // A SUMMONED unit (warlock pet) carries its owner in SUMMONEDBY/CREATEDBY — what the // client needs to treat the unit as this player's pet (frame binding). Wild creatures skip @@ -610,7 +610,7 @@ pub(crate) fn realm_datetime(secs_since_unix_epoch: u64) -> DateTime { ) } -/// Emit the post-login SMSG sequence, gateway translation §5) to the owner, in the +/// Emit the post-login SMSG sequence (gateway translation §5) to the owner, in the /// order the client expects — `SMSG_LOGIN_VERIFY_WORLD` must precede `SMSG_TUTORIAL_FLAGS`, /// and is sent ONLY for a [`WorldEntry::FreshLogin`] (see the enum's doc for why a world-port /// re-entry must omit it). diff --git a/gateway/src/codec/item.rs b/gateway/src/codec/item.rs index da9bc805f..2e8aa8fb4 100644 --- a/gateway/src/codec/item.rs +++ b/gateway/src/codec/item.rs @@ -1,12 +1,11 @@ -//! Item wire mapping: the `CMSG_ITEM_QUERY_SINGLE` reply and the item CREATE_OBJECT (items -//! slice-1), plus the flattened template/instance row views. Pure code-motion out of `mod.rs`. +//! Item wire mapping: the `CMSG_ITEM_QUERY_SINGLE` reply and the item CREATE_OBJECT, plus the flattened template/instance row views. Pure code-motion out of `mod.rs`. use super::*; use lyracore_shared::item::{item_class, weapon_subclass, ItemRefusal, Proficiency}; /// An item-template row as the gateway reads it from `game_item_template`, flattened for the -/// `CMSG_ITEM_QUERY_SINGLE` reply + the item CREATE (items slice-1). Decoupled from the SDK row +/// `CMSG_ITEM_QUERY_SINGLE` reply + the item CREATE. Decoupled from the SDK row /// type. `class`/`subclass` together pick the typed `ItemClassAndSubClass`; the rest map 1:1. #[derive(Clone, Debug, Default)] pub struct ItemTemplateView { @@ -82,7 +81,7 @@ pub struct ItemTemplateView { pub random_property: u32, } -/// An owned-item instance as the gateway reads it from `game_item_instance` (items slice-1), +/// An owned-item instance as the gateway reads it from `game_item_instance`, /// joined with its template for the CREATE descriptors. Decoupled from the SDK row type. #[derive(Clone, Debug, Default)] pub struct ItemInstanceView { @@ -452,7 +451,7 @@ pub fn build_buy_bank_slot_reply(outcome: Result<(), &str>) -> SMSG_BUY_BANK_SLO build_buy_bank_slot_result(result) } -/// Build the item or container CREATE_OBJECT (items slice-1 / bag extension). A non-spatial object: +/// Build the item or container CREATE_OBJECT. A non-spatial object: /// NO living/position movement block — just `UPDATEFLAG_ALL`. Branches on `inst.container_slots`: /// - Regular items (0): `ObjectType::Item` + `UpdateMask::Item` (baseline-safe, byte-identical). /// - Bags (> 0): `ObjectType::Container` + `UpdateMask::Container` with `CONTAINER_FIELD_NUM_SLOTS` diff --git a/gateway/src/codec/loot.rs b/gateway/src/codec/loot.rs index 039ce261d..09309c1a8 100644 --- a/gateway/src/codec/loot.rs +++ b/gateway/src/codec/loot.rs @@ -12,10 +12,10 @@ const SMSG_LOOT_RESPONSE_OPCODE: u16 = 0x0160; /// One lootable item for the loot window: `(slot, item_id, count, display_id, random_property_id)`. `display_id` is the /// item's `ItemDisplayInfo` id (the gateway joins it from `game_item_template`); the client also -/// queries the item (`CMSG_ITEM_QUERY_SINGLE`) for the name/tooltip (slice-1 path). +/// queries the item (`CMSG_ITEM_QUERY_SINGLE`) for the name/tooltip (item-query path). pub type LootItemView = (u8, u32, u32, u32, u32); -/// Build a RAW `SMSG_LOOT_RESPONSE` (slice 3 money + slice 4 items). RAW because gtker's typed +/// Build a RAW `SMSG_LOOT_RESPONSE` (money and items). RAW because gtker's typed /// `LootItem` is INCOMPLETE for vanilla 1.12 — it encodes only `index:u8 + item:u32 + ty:u8` (6 /// bytes), omitting the `count`/`display_id`/`random_suffix`/`random_property_id` (u32×4) the 5875 /// client reads per item (mangos sends 22 bytes/item). Sending items through the typed builder would @@ -60,12 +60,12 @@ pub fn build_loot_removed(slot: u8) -> SMSG_LOOT_REMOVED { SMSG_LOOT_REMOVED { slot } } -/// Build `SMSG_LOOT_MONEY_NOTIFY` — tells the client how much coin was just looted (slice 3). +/// Build `SMSG_LOOT_MONEY_NOTIFY` — tells the client how much coin was just looted. pub fn build_loot_money_notify(amount: u32) -> SMSG_LOOT_MONEY_NOTIFY { SMSG_LOOT_MONEY_NOTIFY { amount } } -/// Build `SMSG_LOOT_RELEASE_RESPONSE` — acks closing the loot window (slice 3). `unknown1 = 1` per +/// Build `SMSG_LOOT_RELEASE_RESPONSE` — acks closing the loot window. `unknown1 = 1` per /// the reference emulators (mangos/cmangos/vmangos). pub fn build_loot_release_response(guid: u64) -> SMSG_LOOT_RELEASE_RESPONSE { SMSG_LOOT_RELEASE_RESPONSE { diff --git a/gateway/src/codec/mod.rs b/gateway/src/codec/mod.rs index 843c15024..191e22b34 100644 --- a/gateway/src/codec/mod.rs +++ b/gateway/src/codec/mod.rs @@ -214,7 +214,7 @@ use wow_world_messages::vanilla::{ UpdatePlayer, UpdateUnit, Vector3d, - // Items slice-2: equip → render on the 3D model via PLAYER_VISIBLE_ITEM. + // Items: equip → render on the 3D model via PLAYER_VISIBLE_ITEM. VisibleItem, VisibleItemIndex, Weekday, @@ -245,7 +245,7 @@ use wow_world_messages::vanilla::{ SMSG_INSPECT, // Inventory-action failure feedback (equip / move / store / use rejection). SMSG_INVENTORY_CHANGE_FAILURE, - // Items slice-1: the item-query response + the typed item CREATE_OBJECT descriptors. + // Items: the item-query response + the typed item CREATE_OBJECT descriptors. // UpdateContainer is the container (bag) analogue of UpdateItem — same ITEM+CONTAINER mask. SMSG_ITEM_QUERY_SINGLE_RESPONSE, SMSG_LEARNED_SPELL, diff --git a/gateway/src/codec/tests.rs b/gateway/src/codec/tests.rs index 57af8b76f..427746d2c 100644 --- a/gateway/src/codec/tests.rs +++ b/gateway/src/codec/tests.rs @@ -282,7 +282,7 @@ fn resistance_values_is_unit_only_no_object_type() { #[test] fn dynamic_flags_values_is_unit_only_no_object_type() { - // Corpse render bit (slice 2): the killing-blow dynamic_flags update must be a Unit VALUES + // Corpse render bit: the killing-blow dynamic_flags update must be a Unit VALUES // mask carrying ONLY UNIT_DYNAMIC_FLAGS — never re-sending OBJECT_FIELD_TYPE (bit 2, value // 0x09 = OBJECT|UNIT), which crashes the 1.12 client. Same dirty_reset discipline as // build_health_values; this guards that the new builder didn't regress it. @@ -317,7 +317,7 @@ fn dynamic_flags_values_is_unit_only_no_object_type() { #[test] fn coinage_values_is_player_mask_without_object_type() { - // The live purse update (slice 3) must be a Player VALUES mask carrying ONLY + // The live purse update must be a Player VALUES mask carrying ONLY // PLAYER_FIELD_COINAGE — never re-sending OBJECT_FIELD_TYPE (0x09). Same dirty_reset guard. let msg = build_coinage_values(1, 0x111); // 273 copper, a recognizable LE pattern match &msg.objects[0] { @@ -551,7 +551,7 @@ fn loot_money_notify_carries_the_share_not_a_hardcoded_total() { #[test] fn ghost_values_is_player_mask_without_object_type() { - // The ghost transition relay (slice 5) carries PLAYER_FLAGS (GHOST) + UNIT_FIELD_BYTES_1 only, + // The ghost transition relay carries PLAYER_FLAGS (GHOST) + UNIT_FIELD_BYTES_1 only, // and must NOT re-send OBJECT_FIELD_TYPE (0x09) — same dirty_reset discipline as the other // partial-VALUES builders (re-sending TYPE crashes the 5875 client). let guid = 1; @@ -588,7 +588,7 @@ fn ghost_values_is_player_mask_without_object_type() { #[test] fn corpse_create_is_a_corpse_object_with_race_in_bytes1() { - // The crash regression (slice 5): the client null-derefs if CORPSE_FIELD_BYTES_1 doesn't carry + // The crash regression: the client null-derefs if CORPSE_FIELD_BYTES_1 doesn't carry // the RACE in byte 1. The module packs bytes_1 = 0 | race<<8 | gender<<16 | skin<<24; this // confirms the codec emits a CORPSE object and passes that word through with race in byte 1. let corpse = CorpseView { @@ -1407,7 +1407,7 @@ fn destroy_object_carries_guid() { } } -// ---- Items slice-1 --------------------------------------------------------------------------- +// ---- Items --------------------------------------------------------------------------- /// The hand-authored "Worn Shortsword" template the gateway maps to the wire (mirrors the /// module seed: a Poor one-hand sword, mainhand-equip, durability 20). @@ -1932,7 +1932,7 @@ fn skill_block_reads_learned_rows_override_and_append() { #[test] fn create_object_carries_the_sheath_state_for_player_and_creature() { - // the CREATE is how a peer entering AOI range learns a unit's sheath state. Omit + // The CREATE is how a peer entering AOI range learns a unit's sheath state. Omit // UNIT_FIELD_BYTES_2 and everyone who walks up to a player with a drawn sword sees them // empty-handed until the next toggle. Byte 0 is the state; bytes 1-3 ride along untouched. let mut e = warrior_entity(); @@ -1970,7 +1970,7 @@ fn create_object_carries_the_sheath_state_for_player_and_creature() { #[test] fn player_create_equips_mainhand_renders_visible_item() { - // Slice-2: a weapon in the main-hand equipment slot (15) must set BOTH the inv-slot guid AND + // a weapon in the main-hand equipment slot (15) must set BOTH the inv-slot guid AND // PLAYER_VISIBLE_ITEM[15] to the item ENTRY, so the 3D model renders the weapon. let item_guid = (0x4000u64 << 48) | (1 << 8) | 15; let msg = build_create_object( diff --git a/gateway/src/codec/update_mask.rs b/gateway/src/codec/update_mask.rs index 4a1d341a0..38c49ebb9 100644 --- a/gateway/src/codec/update_mask.rs +++ b/gateway/src/codec/update_mask.rs @@ -135,6 +135,7 @@ pub mod idx { /// `GAMEOBJECT_ROTATION` — a GAMEOBJECT-descriptor field (its own index space, separate from /// UNIT/PLAYER above), 4 consecutive f32 slots (rot0..3, this + 0..=3). Cross-checked against /// `wow_world_messages` vanilla `UpdateGameObjectBuilder::set_gameobject_rotation` → `set_float(10, + /// ..)`. gtker's typed setter only reaches slot 0 (the descriptor-setter wall, same as multi-aura), /// so all 4 slots ride the hand-rolled raw encoder. `GAMEOBJECT_STATE` follows /// immediately at 14, confirming the 4-slot width. pub const GAMEOBJECT_ROTATION: u16 = 10; diff --git a/gateway/src/codec/values.rs b/gateway/src/codec/values.rs index 2f3ac2ec5..a29210c1f 100644 --- a/gateway/src/codec/values.rs +++ b/gateway/src/codec/values.rs @@ -161,7 +161,7 @@ pub(crate) fn write_packed_guid_u64(out: &mut Vec, guid: u64) { } /// Build a VALUES partial-update carrying `UNIT_DYNAMIC_FLAGS` (idx 143) so a corpse's DEAD bit -/// reaches clients that already have the object (slice 2 killing blow). New observers get it at +/// reaches clients that already have the object (the killing blow). New observers get it at /// CREATE instead (`build_create_object` sets it). Same `dirty_reset` discipline as /// `build_health_values` so the wire carries ONLY this field and never re-sends OBJECT_FIELD_TYPE. pub fn build_dynamic_flags_values(guid: u64, dynamic_flags: u32) -> SMSG_UPDATE_OBJECT { @@ -402,7 +402,7 @@ pub fn build_resistance_values(guid: u64, total: [u32; 7]) -> SMSG_UPDATE_OBJECT } /// Build a VALUES partial-update carrying `PLAYER_FIELD_COINAGE` so the player's money updates LIVE -/// after looting (slice 3). Player mask. Same `dirty_reset` discipline as `build_health_values` so +/// after looting. Player mask. Same `dirty_reset` discipline as `build_health_values` so /// the wire carries ONLY the coinage field and never re-sends OBJECT_FIELD_TYPE (the crash field). pub fn build_coinage_values(guid: u64, money: u32) -> SMSG_UPDATE_OBJECT { player_values(guid, |player| { diff --git a/gateway/src/realm_core.rs b/gateway/src/realm_core.rs index d0535495b..5c1f1a10e 100644 --- a/gateway/src/realm_core.rs +++ b/gateway/src/realm_core.rs @@ -146,7 +146,7 @@ pub(crate) trait RealmDb: Clone + Sized + Send + Sync { /// per-shard population (a point-in-time snapshot, not a windowed average). fn session_count(&self) -> usize; /// Record `shard`'s occupancy + session sample, tagged with `gateway_key` (this gateway - /// process's identity, hashed, `load_sample::gateway_key`, so N gateway processes + /// process's identity, hashed by `load_sample::gateway_key`), so N gateway processes /// sampling the same shard keep independent ring history instead of clobbering each other. fn record_shard_load( &self, diff --git a/gateway/src/stdb/connection.rs b/gateway/src/stdb/connection.rs index b47ec1fd4..e902af469 100644 --- a/gateway/src/stdb/connection.rs +++ b/gateway/src/stdb/connection.rs @@ -1207,7 +1207,7 @@ fn coordinator_queries(sharded_tables: bool) -> Vec<&'static str> { "SELECT * FROM game_taxi_service_reply", "SELECT * FROM game_start_position", "SELECT * FROM game_corpse", - // Items slice-1: the static item definitions (for CMSG_ITEM_QUERY_SINGLE) + every + // Items: the static item definitions (for CMSG_ITEM_QUERY_SINGLE) + every // owned-item instance. The coordinator authenticates as the module owner, which bypasses // RLS, so it reads all players' item rows (like game_character) to drive each login spawn. "SELECT * FROM game_item_template", @@ -1223,7 +1223,7 @@ fn coordinator_queries(sharded_tables: bool) -> Vec<&'static str> { // Buyback ring: read by the coordinator to rebuild a player's buyback-tab view // after sell/buyback and at login (private table — the owner-token coordinator sees it). "SELECT * FROM game_character_buyback", - // Items slice-4: a creature corpse's item loot, read for the loot window. + // Items: a creature corpse's item loot, read for the loot window. "SELECT * FROM game_corpse_loot", // Vendors (Tier 2): a creature's vendor stock, read for SMSG_LIST_INVENTORY. "SELECT * FROM game_npc_vendor", diff --git a/gateway/src/stdb/reads/account.rs b/gateway/src/stdb/reads/account.rs index f916155bd..c66a16171 100644 --- a/gateway/src/stdb/reads/account.rs +++ b/gateway/src/stdb/reads/account.rs @@ -288,7 +288,7 @@ impl Coordinator { } /// Find `owner_guid`'s corpse location `(map_id, x, y, z)` from the privileged cache, for the - /// `MSG_CORPSE_QUERY` reply (slice 5). `None` if they have no corpse. + /// `MSG_CORPSE_QUERY` reply. `None` if they have no corpse. pub fn corpse_location(&self, owner_guid: u64) -> Result> { Ok(self .0 diff --git a/gateway/src/stdb/reads/items.rs b/gateway/src/stdb/reads/items.rs index 1467d63af..f5faf30da 100644 --- a/gateway/src/stdb/reads/items.rs +++ b/gateway/src/stdb/reads/items.rs @@ -11,7 +11,7 @@ use lyracore_shared::group::GroupKind; use lyracore_shared::item_property::client_enchantment_id; impl Coordinator { - /// Read every item a character owns (items slice-1), joined with its template for the CREATE + /// Read every item a character owns, joined with its template for the CREATE /// descriptors (max-durability). Read from the privileged cache (the coordinator bypasses RLS), /// filtered by `owner_guid` — the SDK exposes only the PK index, so iterate+filter like the other /// row queries. Returns the instance views ready for `build_item_create_object` + inventory slots. @@ -131,7 +131,7 @@ impl Coordinator { hit } - /// Read a corpse's item loot for the loot window (items slice-4), joined with each item's + /// Read a corpse's item loot for the loot window, joined with each item's /// template for the display id, then filtered PER VIEWER for `quest_only` rows (quest items are /// per-looter, not gated on whoever got kill credit) AND group-loot rows (a live NEED/GREED /// roll is withheld from EVERYONE; a round-robin/master-designated row is visible only to its @@ -184,7 +184,7 @@ impl Coordinator { Ok(items) } - /// Read a corpse's lootable copper for `SMSG_LOOT_RESPONSE` (slice 3) from the privileged cache. + /// Read a corpse's lootable copper for `SMSG_LOOT_RESPONSE` from the privileged cache. /// Returns 0 if the target is missing or not a corpse — the client only sends `CMSG_LOOT` on a /// lootable corpse, but we stay defensive (an empty loot window is harmless). pub fn loot_target_money(&self, target_guid: u64) -> Result { diff --git a/gateway/src/stdb/reads/templates.rs b/gateway/src/stdb/reads/templates.rs index 761410470..d3d940e0a 100644 --- a/gateway/src/stdb/reads/templates.rs +++ b/gateway/src/stdb/reads/templates.rs @@ -79,7 +79,7 @@ impl Coordinator { .map(|t| t.type_id)) } - /// Read an item template by entry for a `CMSG_ITEM_QUERY_SINGLE` reply (items slice-1). + /// Read an item template by entry for a `CMSG_ITEM_QUERY_SINGLE` reply. pub fn item_template(&self, entry: u32) -> Result> { Ok(self .0 diff --git a/gateway/src/stdb/reducers.rs b/gateway/src/stdb/reducers.rs index 0b5b8879c..ff943b02e 100644 --- a/gateway/src/stdb/reducers.rs +++ b/gateway/src/stdb/reducers.rs @@ -2517,7 +2517,7 @@ impl Coordinator { )) } - /// Take the money from a corpse (`CMSG_LOOT_MONEY`, slice 3) over the coordinator connection so + /// Take the money from a corpse (`CMSG_LOOT_MONEY`) over the coordinator connection so /// the module attributes the loot to the caller (as `gw_loot_money`). pub fn loot_money( &self, @@ -2554,7 +2554,7 @@ impl Coordinator { )) } - /// Take one item from the open corpse into the backpack (`CMSG_AUTOSTORE_LOOT_ITEM`, slice 4) over + /// Take one item from the open corpse into the backpack (`CMSG_AUTOSTORE_LOOT_ITEM`) over /// the coordinator connection so the module attributes the loot to the caller. The module moves the /// item into a free slot + deletes the corpse-loot row (the inventory relay then shows it in the bag). /// Rides the coordinator connection as `gw_take_loot`. @@ -2823,7 +2823,7 @@ impl Coordinator { ) } - /// Persist the rep pane's At-War checkbox (`CMSG_SET_FACTION_ATWAR`, 195 slice B): the wire's + /// Persist the rep pane's At-War checkbox (`CMSG_SET_FACTION_ATWAR`): the wire's /// u16 is the client's 0..63 rep-array slot (ReputationListID — the gtker `Faction` field name /// lies, same as SET_FACTION_STANDING); the module reverse-resolves the faction and upserts. pub fn set_faction_at_war( @@ -3078,7 +3078,7 @@ impl Coordinator { ) } - /// Revive the caller after death (`CMSG_REPOP_REQUEST`, slice 4) over the coordinator connection. + /// Revive the caller after death (`CMSG_REPOP_REQUEST`) over the coordinator connection. /// Rides the coordinator connection as `gw_repop`. pub fn repop(&self, _account_id: u64, actor_guid: u64) -> Result<()> { if actor_guid == 0 { @@ -3092,7 +3092,7 @@ impl Coordinator { ) } - /// Reclaim the caller's corpse (`CMSG_RECLAIM_CORPSE`, slice 5) over the coordinator connection. + /// Reclaim the caller's corpse (`CMSG_RECLAIM_CORPSE`) over the coordinator connection. pub fn reclaim_corpse( &self, _account_id: u64, diff --git a/gateway/src/stdb/subscriptions.rs b/gateway/src/stdb/subscriptions.rs index aff86ba0a..eda66165b 100644 --- a/gateway/src/stdb/subscriptions.rs +++ b/gateway/src/stdb/subscriptions.rs @@ -1140,7 +1140,7 @@ pub(crate) fn relay_gameobject_create( return Vec::new(); }; let view = go_view(row.clone(), &tmpl); - // the CREATE_OBJECT's typed descriptor builder can only carry rot0 of the 4-float spawn + // The CREATE_OBJECT's typed descriptor builder can only carry rot0 of the 4-float spawn // quaternion (gtker's slot-0-only wall), so the other 3 slots — and the yaw-derive fallback for // an all-zero stored quaternion — ride a second raw VALUES frame right behind CREATE. The client // has already materialized the object by the time this second frame arrives (same ordering the @@ -3186,7 +3186,7 @@ fn entity_update_to_outbound_with_dynamic_flags( out.push(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(m))); } // Dynamic-flags VALUES relay: the killing blow sets UNIT_DYNFLAG_LOOTABLE (0x1) on a corpse that - // rolled money (slice 3); the loot reducer clears it. NOTE: UNIT_DYNFLAG_DEAD (0x20) is NEVER set + // rolled money; the loot reducer clears it. NOTE: UNIT_DYNFLAG_DEAD (0x20) is NEVER set // (it is feign-death in vanilla — see combat + lyracore-shared constants). if old.dynamic_flags != new.dynamic_flags { let m = codec::build_dynamic_flags_values(new.guid, dynamic_flags); @@ -3240,13 +3240,13 @@ fn entity_update_to_outbound_with_dynamic_flags( let m = codec::build_sheath_values(new.guid, new.unit_bytes_2); out.push(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(m))); } - // Live XP-bar update (slice 1): players only (creatures never change xp; player-only fields). + // Live XP-bar update: players only (creatures never change xp; player-only fields). let xp_changed = old.xp != new.xp || old.next_level_xp != new.next_level_xp; if xp_changed && is_player { let m = codec::build_player_xp_values(new.guid, new.xp, new.next_level_xp); out.push(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(m))); } - // Live purse update (slice 3): PLAYER_FIELD_COINAGE on money change (looting). Players only. + // Live purse update: PLAYER_FIELD_COINAGE on money change (looting). Players only. if old.money != new.money && is_player { let m = codec::build_coinage_values(new.guid, new.money); out.push(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(m))); diff --git a/gateway/src/stdb/views.rs b/gateway/src/stdb/views.rs index 6102780c1..862189098 100644 --- a/gateway/src/stdb/views.rs +++ b/gateway/src/stdb/views.rs @@ -49,7 +49,7 @@ pub(crate) fn character_view(c: Character) -> crate::codec::CharacterView { } } -/// Flatten a `game_item_template` row into the codec's `ItemTemplateView` (items slice-1). +/// Flatten a `game_item_template` row into the codec's `ItemTemplateView`. pub(crate) fn item_template_view(t: ItemTemplate) -> crate::codec::ItemTemplateView { crate::codec::ItemTemplateView { entry: t.entry, @@ -189,7 +189,7 @@ pub(crate) fn entity_view(e: WorldEntity, durable_zone: u32) -> crate::codec::En } } -/// Map a `game_corpse` row into the codec's `CorpseView` for the CORPSE CREATE_OBJECT (slice 5). +/// Map a `game_corpse` row into the codec's `CorpseView` for the CORPSE CREATE_OBJECT. pub(crate) fn corpse_view(c: Corpse) -> crate::codec::CorpseView { crate::codec::CorpseView { guid: c.guid, diff --git a/gateway/src/world/handlers/char.rs b/gateway/src/world/handlers/char.rs index b34eabf0b..795c65e0d 100644 --- a/gateway/src/world/handlers/char.rs +++ b/gateway/src/world/handlers/char.rs @@ -96,7 +96,7 @@ fn enter_world( "world: entering world guid={character_guid} -> entity at map {} ({:.1},{:.1},{:.1}); subscribing + sending login sequence + self-spawn", entity.map_id, entity.x, entity.y, entity.z ); - // Items slice-1: the character's owned items. Each becomes an item CREATE_OBJECT sent + // Items: the character's owned items. Each becomes an item CREATE_OBJECT sent // BEFORE the player self-spawn (so the inventory-slot guid resolves to an object the // client already has), and the (slot, guid) pairs seed the player's PLAYER_FIELD_INV_SLOT // descriptors. Empty for a character that owns nothing — login is otherwise unchanged. @@ -214,7 +214,7 @@ fn enter_world( open_loot: OpenLootState::default(), ranged_repeat: false, }); - //: the quest-log window. Sent as a separate raw VALUES update AFTER the CREATE + // Quest-log window: sent as a separate raw VALUES update AFTER the CREATE // (gtker's CREATE can't carry these walled fields), gated behind LYRACORE_QUEST_LOG until verified. send_quest_log(tx, store, character_guid)?; // If the player carries ammo (a Projectile item, class 6), tell the client it's loaded @@ -266,7 +266,7 @@ pub(crate) fn handle_char( msg: ClientOpcodeMessage, ) -> Result> { match msg { - // (§4): character-select screen. + // Character-select screen. ClientOpcodeMessage::CMSG_CHAR_ENUM => { let characters = store.characters(conn.account_id)?; let enum_msg = codec::build_char_enum(&characters)?; @@ -336,7 +336,7 @@ pub(crate) fn handle_char( )), )?; } - // (§5): enter world -> register peer subscriptions, then login sequence + self + // Enter world -> register peer subscriptions, then login sequence + self // CREATE_OBJECT2 as one contiguous batch (so an async peer event can't splice into it). ClientOpcodeMessage::CMSG_PLAYER_LOGIN(p) => { let character_guid = p.guid.guid(); @@ -433,7 +433,7 @@ pub(crate) fn handle_char( } } } - //: graceful in-game Logout/Exit. Deny if in combat (vanilla behaviour); otherwise + // Graceful in-game Logout/Exit. Deny if in combat (vanilla behaviour); otherwise // ack instantly + complete, remove the entity (observers see DESTROY), drop the peer // subscriptions, and return to character-select with the connection still open. ClientOpcodeMessage::CMSG_LOGOUT_REQUEST => { diff --git a/gateway/src/world/handlers/loot.rs b/gateway/src/world/handlers/loot.rs index fd90fc6b9..a8c2fd43d 100644 --- a/gateway/src/world/handlers/loot.rs +++ b/gateway/src/world/handlers/loot.rs @@ -494,7 +494,7 @@ pub(crate) fn handle_loot( )), )?; } - // Release Spirit after death (slice 4). The client sends this (empty body) when the player + // Release Spirit after death. The client sends this (empty body) when the player // clicks Release on the death screen. Revive in place at full health; the restored health // replicates via the on_update VALUES relay and the client leaves the death screen. // SMSG_CORPSE_RECLAIM_DELAY is now relay-driven (the escalated per-corpse @@ -509,7 +509,7 @@ pub(crate) fn handle_loot( log::debug!("world: repop ignored (account {}): {e}", conn.account_id); } } - // Corpse location query (slice 5): the client asks where the player's corpse is to draw the + // Corpse location query: the client asks where the player's corpse is to draw the // map marker + offer "Reclaim Corpse" near it. Reply with the corpse's position, or NotFound. ClientOpcodeMessage::MSG_CORPSE_QUERY => { if let WorldState::InWorld(iw) = &conn.state { @@ -522,7 +522,7 @@ pub(crate) fn handle_loot( )?; } } - // Reclaim your corpse (slice 5): the ghost, near its corpse and past the 30s delay, resurrects + // Reclaim your corpse: the ghost, near its corpse and past the 30s delay, resurrects // at 50%. The module validates ownership/ghost/range/delay; a failure (too far, too soon, not // a ghost) is expected and silently ignored — the client just stays a ghost. ClientOpcodeMessage::CMSG_RECLAIM_CORPSE(r) => { diff --git a/gateway/src/world/handlers/query.rs b/gateway/src/world/handlers/query.rs index 033706d9e..701a7e9b9 100644 --- a/gateway/src/world/handlers/query.rs +++ b/gateway/src/world/handlers/query.rs @@ -309,7 +309,7 @@ pub(crate) fn handle_query( _ => send(tx, Outbound::One(ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE))?, } } - // Item template resolution (items slice-1): the client queries an item it has encountered + // Item template resolution: the client queries an item it has encountered // (it holds the object) for its name/tooltip/icon. Always reply — `build_item_query_response` // emits a NotFound (`found: None`) for an unknown entry so the client stops re-asking. ClientOpcodeMessage::CMSG_ITEM_QUERY_SINGLE(q) => { diff --git a/gateway/src/world/mod.rs b/gateway/src/world/mod.rs index 071ad4e47..11b34cc53 100644 --- a/gateway/src/world/mod.rs +++ b/gateway/src/world/mod.rs @@ -1601,7 +1601,7 @@ fn dispatch( let Some(msg) = handle_mail(tx, store, conn, msg)? else { return Ok(()); }; - // (§6): MSG_MOVE_* -> movement_update (persist + relay). The relayed peer events + // MSG_MOVE_* -> movement_update (persist + relay). The relayed peer events // come back on this player's game_movement_event subscription and are re-emitted (same // opcode + verbatim MovementInfo) to other players by their own subscription callbacks. // diff --git a/gateway/src/world/party_tests.rs b/gateway/src/world/party_tests.rs index 7266107b8..447a7ceed 100644 --- a/gateway/src/world/party_tests.rs +++ b/gateway/src/world/party_tests.rs @@ -1507,7 +1507,7 @@ fn a_stale_character_row_on_another_shard_cannot_make_a_logged_in_player_look_se /// **AC: the bot's membership reaches the shard it stands on.** /// -/// The bot's own in-world behaviour — follow-the-leader (the playerbot simulation's slice 2), the +/// The bot's own in-world behaviour — follow-the-leader (the playerbot simulation), the /// kill-XP split, `/p` — all read the SHARD's mirror, not realm-core. The answer therefore has to /// happen before the mirror push of the op that caused it, or the bot is a member the shard does not /// know about until the party's next op (and a bot party has no next op — the human does everything). diff --git a/gateway/src/world/store.rs b/gateway/src/world/store.rs index ac13e869d..c89c3f7e7 100644 --- a/gateway/src/world/store.rs +++ b/gateway/src/world/store.rs @@ -824,7 +824,7 @@ pub trait WorldStore: action_type: u8, ) -> Result<()>; - /// Persist the rep pane's At-War checkbox (`CMSG_SET_FACTION_ATWAR`, 195 slice B). + /// Persist the rep pane's At-War checkbox (`CMSG_SET_FACTION_ATWAR`). /// `reputation_index` is the client's 0..63 rep-array slot, NOT a faction id. fn set_faction_at_war( &self, @@ -1268,7 +1268,7 @@ pub trait WorldStore: Ok(Vec::new()) } - /// Revive the caller after death (`CMSG_REPOP_REQUEST` / Release Spirit, slice 4): the module + /// Revive the caller after death (`CMSG_REPOP_REQUEST` / Release Spirit): the module /// restores full health in place and clears the dead state (the client leaves the death screen /// once the restored health replicates). fn repop(&self, account_id: u64, self_guid: u64) -> Result<()>; @@ -1287,7 +1287,7 @@ pub trait WorldStore: /// Close the matching Shard fences before releasing the Realm-core claim. Stale cleanup is inert. fn release_session(&self, token: WorldSessionToken) -> Result<()>; - /// Reclaim the caller's corpse (`CMSG_RECLAIM_CORPSE`, slice 5): the module validates the caller + /// Reclaim the caller's corpse (`CMSG_RECLAIM_CORPSE`): the module validates the caller /// is a ghost owning the corpse, in range, past the reclaim delay, then resurrects at 50%. fn reclaim_corpse(&self, account_id: u64, self_guid: u64, corpse_guid: u64) -> Result<()>; @@ -1339,7 +1339,7 @@ pub trait WorldStore: /// `CMSG_IGNORE_TRADE`, decline via ignore; initiator hears `IgnoreYou`. fn ignore_trade(&self, account_id: u64, self_guid: u64) -> Result<()>; - /// Find `owner_guid`'s corpse location `(map_id, x, y, z)` for `MSG_CORPSE_QUERY` (slice 5). + /// Find `owner_guid`'s corpse location `(map_id, x, y, z)` for `MSG_CORPSE_QUERY`. fn corpse_location(&self, owner_guid: u64) -> Result>; /// Return the `combat_until_ms` timestamp for `player_guid`'s entity row (0 if the entity is not diff --git a/gateway/src/world/tests.rs b/gateway/src/world/tests.rs index fb668343f..08f127c17 100644 --- a/gateway/src/world/tests.rs +++ b/gateway/src/world/tests.rs @@ -11479,7 +11479,7 @@ fn quest_store_at_level(level: u8) -> InMemoryStore { #[test] fn gossip_hello_hides_unlearn_talents_below_level_10() { - // the imported "I wish to unlearn my talents." row (reclassified by the importer to + // The imported "I wish to unlearn my talents." row (reclassified by the importer to // `UNLEARNTALENTS`, since the raw dump column never carries it) must not render for a character // who cannot yet have a talent point. use lyracore_shared::constants::gossip_option; diff --git a/gateway/tests/schema_parity.rs b/gateway/tests/schema_parity.rs index d67e4cb4e..d43816669 100644 --- a/gateway/tests/schema_parity.rs +++ b/gateway/tests/schema_parity.rs @@ -577,8 +577,8 @@ parity_test!(parity_game_region_assignment, "game_region_assignment", lyracore_m key, map_id, region_id, shard, epoch, updated_micros, }); // Party state, authoritative on realm-core and mirrored onto each world shard. -// `game_group_event` earns its entry twice over — it is the one table this slice CHANGED (the -// END-appended `recipient_guid`), and the gateway decodes it on two different connections. +// `game_group_event` earns its entry twice over — it is the one table with an +// END-appended `recipient_guid`, and the gateway decodes it on two different connections. parity_test!(parity_game_group, "game_group", lyracore_module::Group, bindings::group_type::Group, { group_id, leader_guid, loot_method, loot_threshold, rr_cursor, master_looter_guid, group_type, }); diff --git a/importer/src/dbc.rs b/importer/src/dbc.rs index 1086ff37f..fedcd70bf 100644 --- a/importer/src/dbc.rs +++ b/importer/src/dbc.rs @@ -227,7 +227,7 @@ pub fn run(data_dir: &str, args: &Args) -> Result<()> { .filter(|r| r.pet_talent_type != -1) .count(); - // Locks: Lock.dbc → game_lock, the DATA half of open-lock (119 wires enforcement). + // Locks: Lock.dbc → game_lock, the DATA half of open-lock; the Module's lock gate enforces it. // Small table (a few hundred rows in vanilla) — load ALL, same "load ALL rows" convention as the // other DBC-backed lookups above. No Timestamp → plain SQL, same clear+reload shape. let locks: DbcLock = read_table(&mut chain)?; @@ -250,7 +250,7 @@ pub fn run(data_dir: &str, args: &Args) -> Result<()> { } // Load game_skill_line + game_skill_ability + game_skill_availability from SkillLine.dbc / - // SkillLineAbility.dbc / SkillRaceClassInfo.dbc: the skill fabric as data, see + // SkillLineAbility.dbc / SkillRaceClassInfo.dbc (the skill fabric as data, see // module/src/skilldata.rs). All no-Timestamp → plain SQL, same clear+reload shape as the blocks // above. `SkillTiers.dbc` is NOT loaded here (no `game_*` table of its own) — it only feeds // `profession_tier_values` below, which `importer/src/main.rs` uses for the profession trainer @@ -328,13 +328,13 @@ pub fn run(data_dir: &str, args: &Args) -> Result<()> { println!("SkillRaceClassInfo: {sav_count} availability rows"); println!("AuctionHouse: {auction_house_count} houses"); println!("MailTemplate: {mail_template_count} templates"); - // coverage prints (always printed, like the skill lines above). + // Coverage prints (always printed, like the skill lines above). println!("AreaTable: {area_count} areas"); println!("AreaTrigger: {trigger_count} triggers"); println!("WorldSafeLocs: {graveyard_count} graveyards"); - // coverage print (always printed, like the ones above). + // Coverage print (always printed, like the ones above). println!("CreatureFamily: {family_count} families ({tameable_family_count} tameable)"); - // coverage print (always printed, like the ones above). Loud about any Lock.dbc + // Coverage print (always printed, like the ones above). Loud about any Lock.dbc // LocktypeReference id `LOCKTYPE_TO_SKILL_LINE` doesn't cover — see that const's doc comment. if lock_unmapped.is_empty() { println!("Lock: {lock_count} lock indices"); @@ -509,7 +509,7 @@ fn char_base_info_sql(chain: &mut PatchChain) -> Result<(Vec, usize)> { /// The `CharStartOutfit.dbc` (race_class, item) set — shared by `start_item_sql` (the outfit's own /// clear+reload) AND `main.rs`'s `build_createinfo_item_sql`: the cmangos dump's /// `playercreateinfo_item` EXTRAS are deduped against this SAME set, so an item CharStartOutfit -/// already grants for a (race, class) is never double-listed). `race == 0 || class == 0` rows are +/// already grants for a (race, class) is never double-listed. `race == 0 || class == 0` rows are /// skipped (not a real playable combo) and `item == 0` slots (empty) are skipped, matching /// `start_item_sql`'s own filtering exactly. pub(crate) fn outfit_item_set( @@ -1195,10 +1195,10 @@ fn taxi_catalogue_sql( }) } -/// Clear+reload SQL for `game_creature_family` from `CreatureFamily.dbc`: the 188 pet +/// Clear+reload SQL for `game_creature_family` from `CreatureFamily.dbc`: the pet /// system's data half — `CreatureTemplate.creature_family`/`type_flags` already import via /// `main.rs`'s `ct::FAMILY`/`ct::CREATURE_TYPE_FLAGS`, and the Wolf faction fixup already reads -/// `family_id` off the dump; this is the last missing piece, the family lookup table itself). Name +/// `family_id` off the dump; this is the last missing piece, the family lookup table itself. Name /// via `.en_gb` + `sql_text` (apostrophe-escaping), matching `skill_line_sql`'s convention. /// `pet_food_mask`/`pet_talent_type`/`category` are the raw DBC `int32` fields, signed — see /// `game_creature_family`'s doc comment (`module/src/creatures/spawn.rs`) for their meaning. Loads @@ -1247,8 +1247,8 @@ fn locktype_skill_line(locktype_id: u32) -> Option { .map(|&(_, skill)| skill) } -/// Clear+reload SQL for `game_lock` from `Lock.dbc`, the DATA half of open-lock; 119 -/// wires the enforcement). Each Lock.dbc row packs FOUR parallel `[;8]` arrays: `ty`/`property`/ +/// Clear+reload SQL for `game_lock` from `Lock.dbc`, the DATA half of open-lock; the Module's +/// lock gate enforces it. Each Lock.dbc row packs FOUR parallel `[;8]` arrays: `ty`/`property`/ /// `required_skill`/`action` — up to 8 ALTERNATIVE ways to open the SAME lock (e.g. "the right key OR /// enough Lockpicking"). Only the non-`LockType::None` indices are real; a lock with 1 requirement /// emits exactly 1 `game_lock` row (see the module's `GameLock` doc, `module/src/gameobject.rs`). diff --git a/importer/src/main.rs b/importer/src/main.rs index b1aa303f5..e442bb292 100644 --- a/importer/src/main.rs +++ b/importer/src/main.rs @@ -232,7 +232,7 @@ mod cm { mod cmt { // cmangos `creature_movement_template` — the ENTRY-keyed twin of `creature_movement`'s // guid-keyed paths: Deadmines patrols whose MovementType=2 spawns carry no - // per-guid rows). [V] BOTH vintages below are cmangos-1.12-from-knowledge, NOT verified against + // per-guid rows. [V] BOTH vintages below are cmangos-1.12-from-knowledge, NOT verified against // an in-repo dump (none in this sandbox), and no neighbor-anchor is possible (first import from // this table). Vintage is sniffed by COLUMN COUNT, the `npct`/`gmo` precedent: // >= 9 cols: Entry, PathId, Point, PositionX, PositionY, PositionZ, Orientation, WaitTime, ScriptId @@ -321,8 +321,8 @@ mod gz { pub const FACTION: usize = 2; } mod at { - // cmangos `areatrigger_teleport`, VERIFIED against the real dump 2026-07-10; the - // original [V] guess was off by FOUR columns and every imported portal row was garbage — the + // cmangos `areatrigger_teleport`, VERIFIED against the real dump 2026-07-10. The + // original [V] guess was off by FOUR columns and every imported portal row was garbage (the // Deadmines "no entrance" gap). Real row shape, read off the dump's own INSERT: // (id, name, required_level, req_item, req_quest?, req_?, target_map, x, y, z, o, message, ?) // e.g. (78,'Deadmines - Entering',10,0,0,0,36,-14.5732,-385.475,62.4561,1.5708,'You must be at @@ -367,7 +367,7 @@ mod it { // 23 Stackable(confirmed) — the run lands exactly on the existing STACKABLE=23 anchor. pub const REQUIRED_SKILL: usize = 15; pub const REQUIRED_SKILL_RANK: usize = 16; - // RequiredReputationFaction/Rank: the 195 item half. Same anchored run as above. + // RequiredReputationFaction/Rank: the item's reputation gate. Same anchored run as above. pub const REQUIRED_REPUTATION_FACTION: usize = 20; pub const REQUIRED_REPUTATION_RANK: usize = 21; // maxcount: unique-item stack cap. Same anchored run, one column before the @@ -462,7 +462,7 @@ mod qt { // exactly 2 slots before the already-verified SPECIAL_FLAGS=21 (QuestFlags at 20 between them). // Coverage-printed at import time (timed-quest count) rather than hard floor-gated — plausible // this Elwynn/Westfall slice has ZERO genuinely timed quests (escort quests, the usual timed - // pairing, are deliberately out of scope per, so a floor here would risk a false + // pairing, are deliberately out of scope), so a floor here would risk a false // failure on a legitimate import; the coverage print is the fail-LOUD net instead. pub const LIMIT_TIME: usize = 19; pub const SPECIAL_FLAGS: usize = 21; // bit0 = QUEST_SPECIAL_FLAGS_REPEATABLE @@ -707,9 +707,9 @@ mod nt { /// per tier carrying the REAL Spell.dbc learn id as spell_id (so SMSG_LEARNED_SPELL resolves client-side, /// unlike the old 50080-88 markers) + learn_skill_line/learn_skill_cap set (the module routes the buy to /// crate::skill::learn_profession instead of a spell). Tuple: (skill_line, &[(spell_id, tier_index, -/// required_level)]) — `tier_index` (0=Apprentice,1=Journeyman,2=Expert,3=Artisan) replaced a literal cap -/// the real cap now comes from `SkillTiers.dbc` (see `dbc::profession_tier_values`), -/// checked against `LEGACY_TIER_CAPS` by `assert_profession_tier_parity` before it's ever emitted — this +/// required_level)]) — `tier_index` (0=Apprentice,1=Journeyman,2=Expert,3=Artisan) replaced a literal cap. +/// The real cap now comes from `SkillTiers.dbc` (see `dbc::profession_tier_values`), +/// checked against `LEGACY_TIER_CAPS` by `assert_profession_tier_parity` before it's ever emitted. This /// table itself no longer carries the number, just WHICH tier. The gather/secondary lines (Mining/ /// Herbalism/Skinning/Cooking/First Aid) stop at Expert(2) HERE — their Artisan(3) tier is deferred (not /// load-bearing for 1-10); the crafting trios (Alchemy/BS/Tailoring/LW) go to Artisan(3). Tier ids verified @@ -1137,7 +1137,7 @@ where "--pack-out" => a.pack_out = Some(it.next().context("--pack-out needs an output dir")?), // Derive the operator's OWN spawn bounding box for --map from their dump instead of trusting a // remembered rectangle: the Westfall widening needed a way to compute --box from - // real data, not from memory). Prints min/max X/Y/Z + spawn count and returns — writes nothing. + // real data, not from memory. Prints min/max X/Y/Z + spawn count and returns — writes nothing. "--print-extents" => a.print_extents = true, "--spells" => a.spells = true, "--talents" => a.talents = true, @@ -1686,14 +1686,14 @@ fn build_graveyard_zone_sql(dump: &str) -> Vec { /// Clear+reload SQL for `game_areatrigger_teleport` from cmangos `areatrigger_teleport`: /// dungeon entrance/exit portals — trigger id → target map + destination position. `id` here is the -/// SAME `AreaTrigger.dbc` trigger id `game_area_trigger` (209, `dbc.rs::area_trigger_sql`) carries as -/// its primary key: 209 imports the trigger's GEOMETRY (position/shape, from the client DBC), this +/// SAME `AreaTrigger.dbc` trigger id `game_area_trigger` (`dbc.rs::area_trigger_sql`) carries as +/// its primary key: that importer reads the trigger's GEOMETRY (position/shape, from the client DBC), this /// imports its TARGET (from the cmangos world dump) — two independent sources joined only by sharing /// the same id space, exactly like `game_graveyard`/`game_graveyard_zone`'s split. GLOBAL, not /// box-scoped, on purpose: an areatrigger's own geometry can sit anywhere (Moonbrook's entrance is in /// Westfall, its exit is inside map 36), and a portal a party can walk into from outside today's /// creature-ETL box must still resolve — box-scoping this table would silently drop a portal whose -/// TRIGGER lands outside the box even though the trigger volume itself (209's `game_area_trigger`) is +/// TRIGGER lands outside the box even though the trigger volume itself (`game_area_trigger`) is /// never box-filtered either. Same "small global table, own clear+reload" shape as /// `build_graveyard_zone_sql` above. `[V]` column layout — see the `at` module's doc comment; no dump /// in this sandbox to confirm against, and no neighbor-anchor is possible for this brand-new table. @@ -1887,9 +1887,9 @@ fn build_createinfo_item_sql(dump: &str, dbc_dir: Option<&str>) -> Result 0` in the DELETE (not `>= 0`, unlike the unsigned-PK tables elsewhere) matches the @@ -2130,8 +2130,8 @@ fn null_to_empty(s: &str) -> &str { /// One npc_text slot: `(male, female, probability)`. Empty strings + probability 0.0 = unused slot. type NpcTextSlot = (String, String, f32); -/// Parse ALL 8 weighted npc_text slots from a raw dump row, the pre-217 importer only -/// kept the first non-empty slot; the client does its own weighted pick over the full 8, see +/// Parse ALL 8 weighted npc_text slots from a raw dump row, not just the first +/// non-empty one. The client does its own weighted pick over the full 8 (see /// `gateway::codec::build_npc_text_update`). Handles both schema vintages `npc_text_first_nonempty` /// already distinguished: /// • Full 81-col rows: male/female text + probability embedded directly per slot. @@ -2545,8 +2545,8 @@ struct QuestEtl { reward_spells: Vec, reward_choices: Vec, relations: Vec, - // GAMEOBJECT giver relations: GO 68 "Wanted Poster" starts q176 Wanted: Hogger, - // GO 55/56 "Lost Guards" corpses drive q37/q45/q71) — the `game_gameobject_quest` twin of `relations`. + // GAMEOBJECT giver relations (GO 68 "Wanted Poster" starts q176 Wanted: Hogger, + // GO 55/56 "Lost Guards" corpses drive q37/q45/q71): the `game_gameobject_quest` twin of `relations`. go_relations: Vec, // Reward mail (T3): one row per in-scope quest that sends a letter at turn-in, from either // `quest_template.RewMailTemplateId` or a `dbscripts_on_quest_end` `SCRIPT_COMMAND_SEND_MAIL` @@ -2558,7 +2558,7 @@ struct QuestEtl { // Collect-objective ReqItemId entries — unioned into the item-template load so every collect-quest // item is guaranteed a template even if item_template ever returns to a referenced subset. req_item_entries: std::collections::HashSet, - // coverage counters: how many imported quests carry a non-zero next_quest_id + // Coverage counters: how many imported quests carry a non-zero next_quest_id // (forward-chained) / limit_time (timed). Reported by the caller's eprintln! coverage line and // backing `FLOOR_QUESTS_CHAINED` in importer/scripts/import-manifest.sh. chained_count: usize, @@ -2591,8 +2591,8 @@ fn parse_ref_pools(dump: &str) -> std::collections::HashMap = Vec::new(); let mut emitted: HashSet = HashSet::new(); - // coverage counters: how many items carry a nonzero resistance (any of the 6 + // Coverage counters: how many items carry a nonzero resistance (any of the 6 // schools) and how many carry a nonzero startquest link — printed after the loop so a re-import // makes the new columns' reach visible without a manual SQL scan. let mut resist_nonzero_count: u64 = 0; @@ -3435,7 +3435,7 @@ fn build_items_and_loot( let spt4: u8 = field(&row, it::SPELLTRIGGER_4).parse().unwrap_or(0); let sp5: u32 = field(&row, it::SPELLID_5).parse().unwrap_or(0); let spt5: u8 = field(&row, it::SPELLTRIGGER_5).parse().unwrap_or(0); - // Skill/reputation gates, mail/plate proficiency + the 195 item half. + // Skill/reputation gates: mail/plate proficiency and the reputation requirement. let req_skill: u32 = field(&row, it::REQUIRED_SKILL).parse().unwrap_or(0); let req_skill_rank: u32 = field(&row, it::REQUIRED_SKILL_RANK).parse().unwrap_or(0); let req_rep_faction: u32 = field(&row, it::REQUIRED_REPUTATION_FACTION) @@ -3444,7 +3444,7 @@ fn build_items_and_loot( let req_rep_rank: u32 = field(&row, it::REQUIRED_REPUTATION_RANK) .parse() .unwrap_or(0); - // maxcount/flags/page_text/startquest/bag_family, data plumbing only. + // Maxcount/flags/page_text/startquest/bag_family, data plumbing only. let max_count: u32 = field(&row, it::MAX_COUNT).parse().unwrap_or(0); let item_flags: u32 = field(&row, it::FLAGS).parse().unwrap_or(0); let page_text: u32 = field(&row, it::PAGE_TEXT).parse().unwrap_or(0); @@ -3689,7 +3689,7 @@ pub(crate) fn row_in_slice(args: &Args, map: i64, x: f64, y: f64, z: f64) -> boo /// What `--print-extents` derived from the dump. `anchor` is the in-slice spawn CLOSEST to the /// rectangle's centre — a REAL cmangos ground point, which is exactly what `--center` needs (the /// terrain importer's interpolate self-check compares the ADT height at `--center` against the Z the -/// operator supplied, and the nav importer requires `--center` to rasterize walkable).: +/// operator supplied, and the nav importer requires `--center` to rasterize walkable). /// deriving a second continent's box also means deriving a second continent's centre, and the map-0 /// default (Northshire) is off-slice there. struct Extents { @@ -3700,9 +3700,9 @@ struct Extents { } /// Pure spawn-bbox computation for `--print-extents`: scan every `creature` row on `map` — optionally -/// pre-filtered to a CANDIDATE `--box` rectangle: a whole continent's extents are the -/// continent, so deriving a starting-zone box is an iterate-and-narrow loop — pass a rough rectangle, -/// read back the real spawn extents and count inside it) — returning `None` if no row matched +/// pre-filtered to a CANDIDATE `--box` rectangle. A whole continent's extents are the +/// continent, so deriving a starting-zone box is an iterate-and-narrow loop: pass a rough rectangle, +/// read back the real spawn extents and count inside it. Returns `None` if no row matched /// (nothing to report). Split out from `print_extents` so the corner math is unit-tested against a /// synthetic dump without stdout. fn compute_extents(dump: &str, map: i64, bbox: Option<(f64, f64, f64, f64)>) -> Option { @@ -3749,7 +3749,7 @@ fn compute_extents(dump: &str, map: i64, bbox: Option<(f64, f64, f64, f64)>) -> /// The cmangos `playercreateinfo` start positions ON `map`, as `(race, class, zone, x, y, z)` — the /// canonical starting-zone ANCHORS of a continent: "where does a Kalimdor box go?" is -/// answered by the dump's own Valley of Trials / Camp Narache / Shadowglen rows, not from memory). +/// answered by the dump's own Valley of Trials / Camp Narache / Shadowglen rows, not from memory. /// Pure so it is unit-testable without stdout; printed by `print_extents` below. fn start_positions_on_map(dump: &str, map: i64) -> Vec<(u32, u32, u32, f64, f64, f64)> { parse_table(dump, "playercreateinfo") @@ -3836,9 +3836,9 @@ pub(crate) fn run_sql_statements(args: &Args, stmts: &[String], label: &str) -> /// clear+reload block the `--dump` ETL below already builds (the inventory `import-world.sh`'s header /// comment describes: "the ETL is clear+reload PER FAMILY"). `--family` covers THESE families only — /// terrain (`--terrain`), client DBCs (`--dbc`), and Spell.dbc (`--dbc --spells`) are separate -/// invocations with no family concept of their own. "globals" is the P1/P3/212 slice (start -/// positions, the class/level stat curve, graveyard-zone links, createinfo spells/actions/items, -/// Area Trigger dungeon portals, not box-scoped, but still its own +/// invocations with no family concept of their own. "globals" covers start +/// positions, the class/level stat curve, graveyard-zone links, createinfo spells/actions/items and +/// Area Trigger dungeon portals. They are not box-scoped, but each is its own /// clear+reload, so `--family quests` reloads QUESTS ALONE (not /// quests-plus-globals). "spellmeta" (reduced scope) is the same "global, not box-scoped, /// own clear+reload" shape as "globals" — kept as its OWN family rather than folded into "globals" @@ -3923,8 +3923,8 @@ fn template_is_importable(row: &[String]) -> bool { field(row, ct::UNIT_FLAGS).parse::().unwrap_or(0) & UNIT_FLAG_NOT_SELECTABLE == 0 } -/// Build the full `--dump` ETL plan's `--family` split lives entirely in here, via -/// `family_active` gates around each family's DELETE+INSERT block): parses the cmangos dump, filters +/// Build the full `--dump` ETL plan. The `--family` split lives entirely in here, via +/// `family_active` gates around each family's DELETE+INSERT block. It parses the cmangos dump, filters /// the content slice, maps every family's rows, and returns the ready-to-apply plan. Pure w.r.t. the /// outside world (no `spacetime call`/SQL execution) — `main` does that against the returned /// `DumpPlan`. `--family` gates ONLY which blocks are pushed into `stmts`/counted into `stamps`; every @@ -4257,7 +4257,7 @@ fn collect_creature_spawns(dump: &str, scope: &WorldImportScope) -> Vec = Vec::new(); let mut pool_resolved = 0u32; @@ -4294,7 +4294,7 @@ fn collect_creature_spawns(dump: &str, scope: &WorldImportScope) -> Vec = std::collections::HashMap::new(); - // entry → PickpocketLootId / SkinLootId, same "0 = no table" convention as LootId. + // Entry → PickpocketLootId / SkinLootId, same "0 = no table" convention as LootId. let mut creature_pickpocket_ids: std::collections::HashMap = std::collections::HashMap::new(); let mut creature_skin_ids: std::collections::HashMap = @@ -4728,7 +4728,7 @@ fn build_packed_spawn_payload( .iter() .filter(|s| !excluded_entries.contains(&s.1)) .map(|(db_guid, entry, x, y, z, o, mt, respawn_secs, map)| { - // `map` is the ROW's own map, an --include-map spawn (Deadmines 36) + // `map` is the ROW's own map: an --include-map spawn (Deadmines 36) // must land on ITS map, not the primary --map's. The dump Z rides through UNALTERED on // every path: the importer NEVER snaps spawn Z to terrain (there is no ground_z here at // all — runtime creature legs snap with a keep-current-Z fallback, module terrain.rs), @@ -4993,8 +4993,7 @@ fn gameobject_template_rows(spawns: &GameobjectSpawns) -> GameobjectTemplateRows .. } = spawns; // Template rows for the GOs actually spawned, split by type (`go_template_row`) — a SQL INSERT must - // name EVERY column (#[default] is NOT applied on INSERT, only on migration — slice-5 data-loss - // lesson). Also builds the per-type coverage histogram + the CHEST lootId set (widened to EVERY + // name EVERY column (#[default] is NOT applied on INSERT, only on migration). Also builds the per-type coverage histogram + the CHEST lootId set (widened to EVERY // imported chest, not a curated allowlist and its scoping now spans the whole live set). let mut go_template_rows: Vec = Vec::new(); let mut go_trap_rows: Vec = Vec::new(); @@ -5139,8 +5138,8 @@ fn build_trainer_spell_rows( // Synthesize the profession LEARN rows: for each in-box trainer teaching one of our 12 lines, emit // the Apprentice→Artisan tiers (real Spell.dbc id → SMSG_LEARNED_SPELL resolves). cost 0 (the // gossip-learn is free in vanilla). Skip a tier whose spell id already collides with a class/recipe - // row for THIS trainer (so a trainer can't double-insert the same spell_id). Every column is NAMED - // (slice-5 lesson). The cap comes from the REAL client's SkillTiers.dbc data when + // row for THIS trainer (so a trainer can't double-insert the same spell_id). Every column is NAMED. + // The cap comes from the REAL client's SkillTiers.dbc data when // --dbc was given (already parity-checked equal to LEGACY_TIER_CAPS above); without --dbc there's no // client data to resolve, so it falls back to LEGACY_TIER_CAPS directly (documented fallback). let mut synthesized = 0usize; @@ -6069,14 +6068,14 @@ mod tests { } } - // ----: widened GO import, classifier, per-type row builder, TYPE-25 guard ---- + // ---- widened GO import, classifier, per-type row builder, TYPE-25 guard ---- #[test] fn classify_go_type_remaps_gather_and_passes_everything_else_through_identity() { // A GATHER_NODES entry (real cmangos type 3, a "locked CHEST" in cmangos' own model) remaps to // the synthetic 25 marker — this is the ONLY entry point that ever produces stored type 25. assert_eq!(classify_go_type(1731, 3), Some(GO_GATHER)); // Copper Vein - // A type-3 entry NOT in GATHER_NODES is a real CHEST now widened import, used + // A type-3 entry NOT in GATHER_NODES is a real CHEST now (the widened import used // to require the TREASURE_CHESTS allowlist; ANY chest imports today). assert_eq!(classify_go_type(999_999, 3), Some(GO_CHEST)); assert_eq!(classify_go_type(106318, 3), Some(GO_CHEST)); // Battered Chest @@ -6219,7 +6218,7 @@ mod tests { #[test] fn cr_columns_map_spawn_time_and_movement_type() { - // creature (spawn) row layout, guid,id,map,spawnMask,x,y,z,o, + // Creature (spawn) row layout, guid,id,map,spawnMask,x,y,z,o, // spawntimesecsmin,spawntimesecsmax,spawndist,MovementType — verify the column CONSTANTS // actually land on the cmangos min-respawn-seconds and MovementType fields, not neighbors // (a silent off-by-one here would misread every imported creature's respawn timer). @@ -6258,7 +6257,7 @@ mod tests { assert_eq!(e.max, (-9000.0, 2000.0, 50.0)); // A map with no spawns at all reports None (nothing to derive a box from). assert!(compute_extents(dump, 7, None).is_none()); - // map 1's extents are derived from the MAP-1 rows alone, a second continent's box + // Map 1's extents are derived from the MAP-1 rows alone, a second continent's box // comes out of the same one code path, never out of the map-0 numbers. let k = compute_extents(dump, 1, None).expect("map 1 has one spawn"); assert_eq!( @@ -6294,7 +6293,7 @@ mod tests { #[test] fn start_positions_are_listed_for_the_requested_map_only() { - // the printed starting-zone anchors come from the dump's OWN playercreateinfo, which + // The printed starting-zone anchors come from the dump's OWN playercreateinfo, which // is how a Kalimdor box gets drawn around Valley of Trials instead of around a remembered // coordinate. Human(1) is map 0; Orc(2)/Tauren(6) are map 1. let dump = "x INSERT INTO `playercreateinfo` VALUES \ @@ -6327,7 +6326,7 @@ mod tests { #[test] fn build_graveyard_zone_sql_parses_id_ghost_zone_and_faction() { - // cmangos game_graveyard_zone columns are (id, ghost_zone, faction), id is + // Cmangos game_graveyard_zone columns are (id, ghost_zone, faction), id is // the world_safe_locs/game_graveyard id (safe_loc_id here), ghost_zone the zone it serves, // faction 0 = both / else a team-faction id. Northshire+Goldshire+Eastvale serve zone 12 // (Alliance-only, 469); Sentinel Hill serves zone 40 (also 469). @@ -6351,7 +6350,7 @@ mod tests { #[test] fn build_areatrigger_teleport_sql_maps_columns_by_tuple_position() { - // cmangos areatrigger_teleport columns are (id, name, target_map, + // Cmangos areatrigger_teleport columns are (id, name, target_map, // target_position_x, target_position_y, target_position_z, target_orientation) — see the `at` // module's `[V]` doc comment. Two rows with DISTINCT values in every column (no accidental // digit reuse across fields) so a column-index typo/transposition would fail this test, not @@ -6484,7 +6483,7 @@ mod tests { ); } - /// the dropped item_template columns (resistances, extra spell slots, skill/rep + /// The dropped item_template columns (resistances, extra spell slots, skill/rep /// gates, maxcount/flags/page_text/startquest/bag_family) parse from their VERIFIED cmangos /// column indices into the right positions of the emitted `game_item_template` row, appended /// after `bonding` in struct order. Builds a synthetic 121-column row (indices 0..=120, reaching @@ -6633,7 +6632,7 @@ mod tests { assert_eq!(normalize_item_mask("-1", ALL_PLAYABLE_RACE_MASK), 0xff); } - // ---: playercreateinfo_* fixture tests ------------------------------------------ + // --- playercreateinfo_* fixture tests ------------------------------------------ #[test] fn build_createinfo_spell_sql_parses_race_class_spell_and_wipes_wholesale() { @@ -6941,8 +6940,8 @@ mod tests { #[test] fn family_spellmeta_filter_produces_only_spellmeta_family_stmts() { - // `--family spellmeta`, reduced scope, mirroring the 216 `--family` gate - // parity pattern): the plan must carry ONLY the three spellmeta blocks — no other family's + // `--family spellmeta` (reduced scope) mirrors the `--family` gate parity + // pattern: the plan must carry ONLY the three spellmeta blocks — no other family's // statements — even on a dump that also carries an in-box quest fixture. let dump = format!( "{} INSERT INTO `spell_chain` VALUES (133,0,133,1,0); \ @@ -7028,7 +7027,7 @@ mod tests { assert_eq!(plan.stamps, vec![("spellmeta", 5u64)], "{:?}", plan.stamps); } - // ---: gossip completeness (menu options, multi-slot npc_text, conditions) -------- + // --- gossip completeness (menu options, multi-slot npc_text, conditions) -------- /// Build a `creature_template` INSERT tuple wide enough to reach `ct::GOSSIP_MENU_ID` (col 77): /// entry at col 0, gossip_menu_id at col 77, everything else `0`. @@ -7434,7 +7433,7 @@ mod tests { ); } - // ---: import pipeline tooling (--family, --source-sha, provenance stamps) -------- + // --- import pipeline tooling (--family, --source-sha, provenance stamps) -------- #[test] fn sha256_hex_matches_known_test_vectors() { @@ -7722,7 +7721,7 @@ mod tests { assert!(format!("{removed:#}").contains("scope is empty")); } - // ----: --include-map (the Deadmines map-36 slice) ------------------------------ + // ---- --include-map (the Deadmines map-36 slice) ------------------------------ #[test] fn row_in_slice_keeps_box_geometry_for_the_primary_map_and_whole_map_for_included() { @@ -9678,7 +9677,7 @@ mod tests { ); } - // ---: bad input / missing files / partial datasets --------------------------------------- + // --- bad input / missing files / partial datasets --------------------------------------- // The importer's whole job is to eat an operator-supplied file we have never seen. Every test // below pins what happens when that file is WRONG — missing, truncated, corrupt, or half a // schema — because the failure mode we cannot afford is a silent one: an import that "succeeds" diff --git a/importer/src/spell.rs b/importer/src/spell.rs index bbd076917..2577a23a5 100644 --- a/importer/src/spell.rs +++ b/importer/src/spell.rs @@ -428,8 +428,8 @@ fn power_burn_ratio_bp(multiple: f32) -> i32 { /// graceful `E_SCRIPTED` no-op. IDs verified against `wow_world_base::vanilla::SpellEffect` (the /// `from_int` table): SchoolDamage=2, Heal=10, HealMaxHealth=67, Energize=30, Dispel=38, /// TriggerSpell=64, Threat=63/ThreatAll=91, weapon-damage family 17/58/121/31, Resurrect=18, -/// AddComboPoints=80, ResurrectNew=113, AttackMe=114, cross-checked against the -/// vendored `wow_world_base-0.3.0` vanilla/tbc/wrath `SpellEffect::from_int` tables, all three eras +/// AddComboPoints=80, ResurrectNew=113, AttackMe=114 (cross-checked against the +/// vendored `wow_world_base-0.3.0` vanilla/tbc/wrath `SpellEffect::from_int` tables; all three eras /// agree on these four numeric ids). /// CONFIRMED 2026-07-14 against the real client Spell.dbc (curated-kit dry-run + targeted --only): /// Taunt 355 → E_TAUNT, Resurrection 2006 / Redemption 7328 → E_RESURRECT, Cheap Shot 1833 (no @@ -486,7 +486,7 @@ fn correct_script_effect_kind(name: &str, kind: u8) -> u8 { if kind == E_DAMAGE && matches!(name, "Heroic Strike" | "Cleave") { return E_NEXT_SWING; } - // Feint (Rogue Slice 3): vanilla encodes it as the native Threat effect (→ E_TAUNT) with a NEGATIVE + // Feint: vanilla encodes it as the native Threat effect (→ E_TAUNT) with a NEGATIVE // base (−150) — i.e. a one-time threat DROP, not a taunt-yank. Reclassify the E_TAUNT effect to our // E_REDUCE_THREAT (the handler reduces the caster's CURRENT threat by |base_points|). Fires on the // real E_TAUNT effect, before the residue guard. DISTINCT from Fade (a COMBAT_THREAT percent). @@ -514,7 +514,7 @@ fn correct_script_effect_kind(name: &str, kind: u8) -> u8 { // Garrote's eff2 is the same combo Dummy (its eff1 is the A_PERIODIC_DAMAGE bleed, unchanged). // `instant_effect_to_kind` now maps the raw AddComboPoints effect id (80) to // E_ADD_COMBO natively too, but the in-kit generators carry the generic Dummy effect, not raw - // 80, so this name rescue is still the ONLY path that reaches them — kept, not redundant here.) + // 80, so this name rescue is still the ONLY path that reaches them — kept, not redundant here. "Sinister Strike" | "Backstab" | "Gouge" | "Garrote" => E_ADD_COMBO, "Resurrection" | "Redemption" | "Rebirth" | "Ancestral Spirit" => E_RESURRECT, @@ -1668,12 +1668,12 @@ fn resolve_effect_kind( } else { kind }; - // GROUND-AoE (118): a ground-persistent A_PERIODIC_DAMAGE is a FIXED-POSITION area, not a unit + // GROUND-AoE: a ground-persistent A_PERIODIC_DAMAGE is a FIXED-POSITION area, not a unit // DoT. The DBC encodes it as A_PERIODIC_DAMAGE with a dynobj/self target that resolves WRONG — // Consecration → T_SELF would DoT the paladin himself. Reclassify BY NAME to E_PERSISTENT_AREA // (the Charge/Blink name-rescue precedent) so it spawns a game_ground_area whose own // tick_ground_areas damages hostiles inside. Consecration is caster-anchored; Flamestrike (262) - // is the first CLICKED-GROUND one, the 118 dest plumbing (6067df1) anchors the area at + // is the first CLICKED-GROUND one: the ground-target dest plumbing anchors the area at // the click when the cast carries a DEST_LOCATION block, so the same kind serves both. // Blizzard/Rain of Fire remain un-rescued (channeled patches — their channel/tick interplay is // its own follow-up; leaving them A_PERIODIC_DAMAGE keeps them out of the curated kit). @@ -1779,7 +1779,7 @@ fn trainer_offering_rows( Some(&lvl) => { let cost = trainer_cost(lvl); // id=0 → the table's #[auto_inc] PK assigns the real id on insert. END-append - // learn_skill_line=0, learn_skill_cap=75 (professions slices 3 + rank/cap): every + // learn_skill_line=0, learn_skill_cap=75: every // class-spell offering is a normal spell row (the unchanged spell path); profession // offerings are parent-SQL-seeded. The cap is unused on a line-0 row but must be NAMED // on the INSERT; 75 matches the module's #[default(75u32)]. @@ -2156,7 +2156,7 @@ pub fn run_spells(data_dir: &str, args: &Args) -> Result<()> { }, ); // Provenance stamp convention, mirrored from the --dump loop's per-family - // stamp_family calls in main.rs::run_dump). file_hash is "" here: unlike the --dump path + // stamp_family calls in main.rs::run_dump. file_hash is "" here: unlike the --dump path // (which hashes the whole SQL dump's bytes in one shot), the DBC chain is read row-by-row // through `read_table`'s parsed-struct API (dbc.rs) with no single raw byte buffer to hash // cheaply — threading a chain-wide hash would mean re-reading every source MPQ a second time @@ -2916,7 +2916,7 @@ mod tests { E_CONVERT_RESOURCE ); assert_eq!(correct_script_effect_kind("Charge", E_SCRIPTED), E_CHARGE); - // Feint (Rogue Slice 3): vanilla encodes it as the native Threat effect (→ E_TAUNT) with a + // Feint: vanilla encodes it as the native Threat effect (→ E_TAUNT) with a // negative base; reclassify that E_TAUNT to E_REDUCE_THREAT (a one-time current-threat drop). assert_eq!( correct_script_effect_kind("Feint", E_TAUNT), @@ -3219,7 +3219,7 @@ mod tests { SPELL_ATTR_INCAP_OPENER ); assert_eq!(spell_flag_attributes("Sap") & SPELL_ATTR_REQ_BEHIND, 0); - // Garrote (Rogue Slice 3): REQ_BEHIND + REQ_STEALTH, but NOT STEALTH_SAFE (it breaks stealth) and + // Garrote: REQ_BEHIND + REQ_STEALTH, but NOT STEALTH_SAFE (it breaks stealth) and // NOT INCAP_OPENER (works on any type, in or out of combat — must not inherit Sap's constraints). assert_eq!( spell_flag_attributes("Garrote") & SPELL_ATTR_REQ_STEALTH, @@ -3242,7 +3242,7 @@ mod tests { spell_flag_attributes("Pick Pocket"), SPELL_ATTR_STEALTH_SAFE ); - // Slice and Dice: the combo-FINISHER duration-scaling bit (Rogue Slice 2) — and ONLY that bit. + // Slice and Dice: the combo-FINISHER duration-scaling bit — and ONLY that bit. assert_eq!( spell_flag_attributes("Slice and Dice"), SPELL_ATTR_FINISHER_DURATION diff --git a/importer/src/terrain.rs b/importer/src/terrain.rs index bf285aa19..63be04cbc 100644 --- a/importer/src/terrain.rs +++ b/importer/src/terrain.rs @@ -7,8 +7,8 @@ //! Heights are rebased to ABSOLUTE world Z here (MCVT is relative to the chunk header Z), so //! the module's `ground_z` is pure arithmetic. Dry-run prints the plan + a self-check; `--apply` //! calls the reducers. Honors `--map/--center/--radius`, or `--box X0,X1,Y0,Y1` in place of -//! `--radius` for an exact rectangle, a wide zone like Westfall+Elwynn together -//! needs a rectangle, not a center-radius circle's bounding square); `--center` still drives the +//! `--radius` for an exact rectangle (a wide zone like Westfall+Elwynn together +//! needs a rectangle, not a center-radius circle's bounding square). `--center` still drives the //! interpolate self-check below, so put it INSIDE the box. use anyhow::{bail, Context, Result}; @@ -107,7 +107,7 @@ fn cell_index(coord: f32) -> i32 { } /// Cell-index range `(x_min, x_max, y_min, y_max)` covering the content slice: a `--box` -/// rectangle when given, the Westfall widening needed an exact rectangle, not a +/// rectangle when given (a wide zone like Westfall needs an exact rectangle, not a /// center±radius bounding square that either misses corners or overshoots into neighbours), else /// the original center±radius square. Cell indices count DOWN as world coordinates grow (see /// `cell_index`), so a box's HIGH world coordinate (x1/y1) maps to the LOW cell index and vice @@ -445,7 +445,7 @@ mod tests { #[test] fn slice_cell_range_from_box_uses_corners_not_center_radius() { - // the widened Westfall box. High world X/Y (x1/y1) map to the LOW cell index + // The widened Westfall box. High world X/Y (x1/y1) map to the LOW cell index // (cell index counts DOWN as world coords grow) — the min/max pairing is corner-derived, not // center±radius, and radius is ignored entirely once a box is given. let (x0, x1, y0, y1) = (-11400.0f64, -8000.0, -1600.0, 2000.0); diff --git a/module/src/character.rs b/module/src/character.rs index 7ea7080fc..650ec5a1d 100644 --- a/module/src/character.rs +++ b/module/src/character.rs @@ -107,7 +107,7 @@ pub struct Character { pub pending_instance_id: u64, /// GM playtest authorization level: `0` = no access to any `.command`; the /// operator-only `gm::set_gm_level` reducer is the only writer. Moderation-facing per-level - /// distinctions beyond "has access at all" are's concern, not this one's, every + /// distinctions beyond "has access at all" are out of scope: every /// `gm_command` today only checks `gm_level != 0`. `#[default(0)]` + END-appended so `publish` /// auto-migrates existing characters to "no GM access" (safe default). Gateway-subscribed /// (`game_character` is in the coordinator's subscription list) → hand-synced in @@ -131,8 +131,7 @@ pub struct Character { /// precedent) + END-appended → auto-migrates. #[default(0u64)] pub rested_since_micros: u64, - /// GM playtest GODMODE carried across an entity REBUILD, the 226 landmine wearing - /// a GM hat): a CROSS-MAP `.tele` (and every cross-database shard hop, which rides the same + /// GM playtest GODMODE carried across an entity REBUILD: a CROSS-MAP `.tele` (and every cross-database shard hop, which rides the same /// primitive) DESPAWNS the live entity, and `build_player_entity` rebuilds it from THIS row — so /// before this column, `.god` was silently dropped on arrival and the GM was eaten by the local /// wildlife with no message. `persist_entity` stamps it from the live entity on every NON-logout diff --git a/module/src/chat.rs b/module/src/chat.rs index cd4866f9c..4de156ba9 100644 --- a/module/src/chat.rs +++ b/module/src/chat.rs @@ -71,7 +71,7 @@ pub fn addon_payload(raw: &str) -> Option { (!raw.is_empty()).then(|| raw.chars().take(MAX_CHAT_LEN).collect()) } -/// The say/yell/`/e` core, actor-explicit (stage 4a): everything the old sender-path `send_chat` +/// The say/yell/`/e` core, actor-explicit: everything the old sender-path `send_chat` /// did after resolving WHO spoke, plus the player/EventAI boundary — a Character may say, yell or /// `/e`, never submit the creature-only text emote. EMOTE is admitted ONLY here, never in /// [`apply_send_chat_to`]: that function is EventAI's own entry, and its `chat_type` comes straight @@ -231,7 +231,7 @@ pub struct EmoteEvent { /// `text_emote` / `emote_anim` ids come from the client; invalid ones degrade gracefully gateway-side /// (the text line is skipped / the animation is dropped) rather than erroring. /// -/// The text-emote core, actor-explicit (stage 4a) — same split as [`apply_send_chat`]. +/// The text-emote core, actor-explicit, same split as [`apply_send_chat`]. pub(crate) fn apply_send_emote( ctx: &ReducerContext, sender: crate::WorldEntity, diff --git a/module/src/combat/engage.rs b/module/src/combat/engage.rs index 56edce7f9..8f3b901cf 100644 --- a/module/src/combat/engage.rs +++ b/module/src/combat/engage.rs @@ -13,7 +13,7 @@ use spacetimedb::{table, ReducerContext, ScheduleAt, Table, Timestamp}; use crate::{game_faction_template, game_world_entity, WorldEntity}; // Tables' pure formulas/consts and the sibling submodules' re-exports (`roll_swing`, `kill_creature`, -//...) are all pulled in from `mod.rs` (`pub use tables::*` + `pub use folds::*`/`death::*`/`swing::*`) +// ...) are all pulled in from `mod.rs` (`pub use tables::*` + `pub use folds::*`/`death::*`/`swing::*`) // — this ALSO brings `tick_melee`/`ranged_impact` into scope for the `MeleeSchedule`/ // `RangedImpactSchedule` tables' `scheduled(..)` macros below to resolve, since those two reducers are // defined in `swing.rs` (mirrors `spell::tables`'s identical cross-file `scheduled(..)` pattern). diff --git a/module/src/combat/swing.rs b/module/src/combat/swing.rs index f2b3c3855..380666dce 100644 --- a/module/src/combat/swing.rs +++ b/module/src/combat/swing.rs @@ -34,7 +34,7 @@ use super::*; /// and a melee row's attacker/target pair shares one instance at ARM time on every arming path — /// `apply_start_attack`/`apply_start_ranged_attack` reject a cross-instance target explicitly, this /// file's `aggro_pass` retaliation mirrors an existing (same-instance) row, and `tick_creatures`'s -/// aggro/assist/pet passes pair within one instance (190 slice 1). So this tick's cost is O(active +/// aggro/assist/pet passes pair within one instance. So this tick's cost is O(active /// engagements) — it scales with combat, NOT with instance count, and scoping it per instance would /// divide an already-small table while adding a per-row entity fetch. (Known pre-existing edge, not /// widened here: 224's `teleport_player` can move one side of a live pair cross-instance AFTER diff --git a/module/src/corpse.rs b/module/src/corpse.rs index 38418b245..96f192cb0 100644 --- a/module/src/corpse.rs +++ b/module/src/corpse.rs @@ -194,7 +194,7 @@ pub(crate) fn apply_reclaim_corpse( .guid() .find(corpse_guid) .ok_or_else(|| "no such corpse".to_string())?; - // Map + instance gated (190 slice 2 — corpse rows carry `instance_id` now): a ghost must + // Map + instance gated (corpse rows carry `instance_id`): a ghost must // corpse-run back into the SAME instance it died in (the areatrigger resolve re-binds it to // that instance, so the run-back lands right); a ghost in another party's copy — or in the // open world — can never reclaim through the wall. diff --git a/module/src/creatures/ai.rs b/module/src/creatures/ai.rs index ea847675e..2dfbce12a 100644 --- a/module/src/creatures/ai.rs +++ b/module/src/creatures/ai.rs @@ -727,7 +727,7 @@ mod tests { ); } - ///'s "mid-leg flee/return either finishes or freezes coherently" requirement: this + /// The "mid-leg flee/return either finishes or freezes coherently" requirement: this /// repo picks FREEZE (dormancy simply skips the pass, touching nothing). That choice is only /// coherent because `chase_step` (the shared return/chase step primitive) is a pure function of the /// CURRENT position and target — no elapsed-time or step-count argument — so pausing for any number @@ -1168,7 +1168,7 @@ mod tests { } // ========================================================================================= - //, per-instance tick scope + cadence-generalized sense/step math + // Per-instance tick scope + cadence-generalized sense/step math // ========================================================================================= /// Pins the sentinel to u64::MAX — the schema `#[default(18_446_744_073_709_551_615u64)]` on @@ -1242,9 +1242,9 @@ mod tests { } } - /// The equivalence-spec pattern, applied to 229: with ONLY the seeded catch-all row + /// The equivalence-spec pattern, applied to the per-instance tick scope: with ONLY the seeded catch-all row /// and every entity at instance 0 (today's world), the scope filter admits the IDENTICAL visit - /// set — the passes behave byte-identically to the pre-229 code (the gate is `covers() == true` + /// set — the passes behave byte-identically to an unscoped tick (the gate is `covers() == true` /// for every candidate, and the global passes still run). #[test] fn tick_scope_default_config_visits_the_identical_candidate_set() { diff --git a/module/src/creatures/spawn.rs b/module/src/creatures/spawn.rs index 1a0a3034b..fcf360086 100644 --- a/module/src/creatures/spawn.rs +++ b/module/src/creatures/spawn.rs @@ -235,12 +235,12 @@ pub struct NpcText { pub text: String, // slot 0, male (back-compat) } -/// The remaining weighted npc_text slots, vanilla ships 8 greeting variants; the +/// The remaining weighted npc_text slots (vanilla ships 8 greeting variants; the /// CLIENT does the random weighted pick from `SMSG_NPC_TEXT_UPDATE`'s 8-slot array, no server RNG, /// see `gateway::codec::build_npc_text_update`). A SEPARATE table from `NpcText`, NOT an end-append /// of it: SpacetimeDB 2.5's `#[table]` macro cannot default a `String` column — `#[default(String::new())]` /// fails to compile (`error[E0493]`: `String`'s `Drop` can't run inside the macro's compile-time -/// type-check, which is a plain `const {.. }` block, this is a hard Rust limitation, not a repo +/// type-check, which is a plain `const { .. }` block, this is a hard Rust limitation, not a repo /// convention, so it applies to ANY end-appended `String` column, verified in this pass). A brand /// NEW table sidesteps it entirely (zero existing rows ⇒ nothing to backfill ⇒ no column needs a /// default at all), and this is the SAME one-row-plus-child-rows shape already used everywhere else @@ -722,7 +722,7 @@ pub fn build_creature_entity( skinned: false, // fresh spawn: corpse not yet skinned — re-set here gives the free per-life reset mana_regen_paused_until_ms: 0, // creatures have no mana pool; FSR never fires (max_power == 0) death_expire_micros: 0, // creatures have no corpse-reclaim escalation (player-only field) - instance_id, // slice 1: every caller passes 0 (open world) + instance_id, // every caller passes 0 (open world) run_speed_mult_bp: 10_000, // 1× — GM `.speed` targets players only godmode: false, // GM `.god` targets players only resting: false, // creatures never rest (196) @@ -874,8 +874,8 @@ pub fn build_player_entity( death_expire_micros: character.death_expire_micros, instance_id: character.pending_instance_id, - // GM playtest fields threaded from the DURABLE carry columns, - // the `death_expire_micros` precedent). They are NOT durable settings: `persist_entity` clears + // GM playtest fields, threaded from the DURABLE carry columns + // (the `death_expire_micros` precedent). They are NOT durable settings: `persist_entity` clears // them on a real logout/disconnect and carries them across a despawn/rebuild WITHIN a session // (`persisted_gm_playtest`), so a login still starts at 1× speed / not-godmode while a // CROSS-MAP `.tele` — which despawns the entity and rebuilds it right here — no longer drops diff --git a/module/src/creatures/tick/lifecycle.rs b/module/src/creatures/tick/lifecycle.rs index 8870ff45c..81a3d3f62 100644 --- a/module/src/creatures/tick/lifecycle.rs +++ b/module/src/creatures/tick/lifecycle.rs @@ -61,9 +61,9 @@ pub(crate) fn despawn_creature_entity(ctx: &ReducerContext, guid: u64) { /// respawn timer armed to a FUTURE time (so respawn does NOT re-create it the same tick). Reaps the /// corpse's item-loot rows. /// -/// classification: STAYS GLOBAL, `despawn_at` is a due-time, not a proximity concern; a -/// corpse must decay on schedule whether or not a player is anywhere near it. -/// catch-all firing only, still covering ALL instances (see +/// Stays GLOBAL: `despawn_at` is a due-time, not a proximity concern, and a +/// corpse must decay on schedule whether or not a player is anywhere near it. Runs on the +/// catch-all firing only, which covers ALL instances (see /// `TickScope::runs_global_passes`). Returns spawn rows scanned. pub(crate) fn pass_decay(ctx: &ReducerContext) -> usize { let now_ts = ctx.timestamp; @@ -174,9 +174,9 @@ pub(crate) fn pass_decay(ctx: &ReducerContext) -> usize { /// elapsed, from its persistent spawn record + template. Runs AFTER decay (decay arms a future /// `respawn_at`, so a just-decayed creature isn't re-spawned this tick). /// -/// classification: STAYS GLOBAL, `respawn_at` is a due-time; a spawn point must -/// repopulate on schedule whether or not a player is nearby to see it happen. -/// catch-all firing only, still covering ALL instances. Returns spawn rows scanned. +/// Stays GLOBAL: `respawn_at` is a due-time, and a spawn point must +/// repopulate on schedule whether or not a player is nearby to see it happen. Runs on the +/// catch-all firing only, which covers ALL instances. Returns spawn rows scanned. pub(crate) fn pass_respawn(ctx: &ReducerContext) -> usize { let now_ts = ctx.timestamp; let entities = ctx.db.game_world_entity(); @@ -232,10 +232,9 @@ pub(crate) fn pass_respawn(ctx: &ReducerContext) -> usize { /// `by_state` index would still have visited every depleted node every tick; the due-time range /// visits only the ones actually due. /// -/// classification: STAYS GLOBAL, `respawn_at_micros` is a due-time (like creature -/// respawn/decay), not proximity; GAMEOBJECTS are also out of this item's creature-ticking scope (see -/// the "defer it" note above, pre-dating 230). -/// catch-all firing only, still covering ALL instances. Returns GO rows scanned. +/// Stays GLOBAL: `respawn_at_micros` is a due-time (like creature +/// respawn/decay), not proximity, and GAMEOBJECTS are outside the creature-ticking scope. Runs on the +/// catch-all firing only, which covers ALL instances. Returns GO rows scanned. pub(crate) fn pass_gameobject_respawn(ctx: &ReducerContext) -> usize { let now = ctx.timestamp.to_micros_since_unix_epoch() as u64; let gos = ctx.db.game_gameobject(); diff --git a/module/src/creatures/tick/mod.rs b/module/src/creatures/tick/mod.rs index 1b48b6c20..05c35f7ec 100644 --- a/module/src/creatures/tick/mod.rs +++ b/module/src/creatures/tick/mod.rs @@ -32,7 +32,7 @@ pub(crate) use lifecycle::{pass_decay, pass_gameobject_respawn, pass_respawn}; // Re-export so `crate::creatures::tick::despawn_creature_entity` (and, via `creatures::mod.rs`'s own // `pub use tick::*`, `crate::creatures::despawn_creature_entity`) still resolves — `encounter.rs`/ -// `instance.rs` call it by that exact path. `pub(crate)`, unchanged from pre-split. +// `instance.rs` call it by that exact path. pub(crate) use lifecycle::despawn_creature_entity; // =========================================================================================== @@ -128,7 +128,7 @@ pub struct CreatureSpline { /// column of that index is matched by an equality term, and it skips any index with more than 3 /// columns outright (`MAX_EXACT_INDEX_COLS`); range predicates are never index-served at all /// (`IndexProbe::Range` — "we currently never construct this variant") and an `OR` is evaluated - /// row-by-row. So a `grid_x BETWEEN.. AND grid_y BETWEEN..` box degrades to a full partition + /// row-by-row. So a `grid_x BETWEEN .. AND grid_y BETWEEN ..` box degrades to a full partition /// scan — 1.1 BILLION rows examined on `game_gameobject` in a 445-player measurement. Folding the /// two grid columns into one makes `by_cell` a 3-column all-equality index, which the planner CAN /// serve, and the AOI box becomes 25 point probes instead of a scan. @@ -176,13 +176,11 @@ pub struct CreatureSpline { /// cadences are a knob to use sparingly, measured via the per-pass rows-visited log below. /// /// PER-INSTANCE `tick_ms` KNOB: the cadence IS `scheduled_at` (`ScheduleAt::Interval(tick_ms)`) on -/// the dedicated row — no separate `tick_ms` column (it would duplicate `scheduled_at`), and no -/// `game_instance` table exists yet to hang it on (190 slice 1 landed only the indexes). -/// SPLICE POINT (190 slice 2): `create_instance` inserts the dedicated row +/// the dedicated row. There is no separate `tick_ms` column, because it would duplicate +/// `scheduled_at`. `create_instance` inserts the dedicated row /// `{scheduled_id: 0, scheduled_at: ScheduleAt::Interval(tick_ms), instance_id: N}` and the instance -/// reap deletes it (which automatically returns coverage of N to the catch-all — pause/slow-when- -/// empty is then "update/delete the dedicated row", also slice-2 lifecycle work). Until then the -/// operator arms one via `debug_arm_instance_tick` (debug.rs). [server] +/// reap deletes it, which returns coverage of N to the catch-all. The operator retunes a row via +/// `debug_arm_instance_tick` (`debug/mod.rs`). [server] #[table(accessor = game_creature_move_schedule, scheduled(tick_creatures))] pub struct CreatureMoveSchedule { #[primary_key] @@ -215,7 +213,7 @@ pub struct CreatureMoveSchedule { /// 0.5s row. This is mangos's one-loop-with-recheck-timers model on one scheduled reducer — a single /// tick, so no cross-scheduler `spline_id` collision. /// -/// INSTANCE SCOPE, latency smoothing + work avoidance, NOT parallelism; see the +/// INSTANCE SCOPE (latency smoothing + work avoidance, NOT parallelism; see the /// `CreatureMoveSchedule` doc): every firing resolves a `TickScope` from ITS OWN schedule row. The /// catch-all row covers every instance without a dedicated row; a dedicated row covers exactly its /// instance. Coverage is a PARTITION, so no creature is ever ticked by two rows. @@ -271,11 +269,10 @@ pub fn tick_creatures(ctx: &ReducerContext, schedule: CreatureMoveSchedule) { } // =========================================================================================== -// Active cells [server],: grid-activation; only cells near players tick +// Active cells [server]: grid-activation; only cells near players tick // =========================================================================================== -/// Rough heartbeat period (micros) for the active-cell rows-visited log line, the -/// done-when evidence. NOT every tick (would spam `RUST_LOG=info` at the 0.5s movement cadence); +/// Rough heartbeat period (micros) for the active-cell rows-visited log line. NOT every tick (would spam `RUST_LOG=info` at the 0.5s movement cadence); /// roughly once a minute is plenty to eyeball the before/after ratio on a live node. const ACTIVE_CELL_LOG_PERIOD_MICROS: i64 = 60_000_000; @@ -407,9 +404,9 @@ pub(crate) struct TickSweep { pub(crate) in_combat: Vec, } -/// done-when evidence: log the active-cell rows-visited/total ratio roughly once a +/// Evidence log for the active-cell sweep: the rows-visited/total ratio, roughly once a /// minute. `total` (a full non-player-entity count) is deliberately gated behind the SAME rare window -/// so the O(N) count itself never reintroduces the per-tick cost this item removes. +/// so the O(N) count itself never reintroduces the per-tick cost the active-cell sweep removes. 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 { @@ -424,7 +421,7 @@ fn log_active_cell_stats(ctx: &ReducerContext, awake: usize) { log::info!("tick_creatures active-cell: {awake}/{total} creatures visited this tick"); } -/// done-when evidence: log the cast/rout/fear rows-visited drop, in +/// Evidence log for the narrowed passes: the cast/rout/fear rows-visited drop, in /// the SAME rare window `log_active_cell_stats` uses (reusing its throttle — no extra per-tick cost). /// `melee_rows` is the candidate universe BOTH the cycle's cast and rout phases outer-loop (identical /// gate: "currently the attacker in `game_melee_attack`"); `fear_rows` is what the fear phase @@ -851,7 +848,7 @@ pub(crate) fn advance_needs_persist( /// The shared gate ladder every ENGAGED/table-driven phase (cast, threat retarget, chase, rout and /// fear) opens its per-candidate loop with: resolve `guid` to a live CREATURE (no PLAYER bit, not -/// dead) whose instance THIS firing's `scope` covers. `None` collapses each site's `let Some(c) =... +/// dead) whose instance THIS firing's `scope` covers. `None` collapses each site's `let Some(c) = ... /// else { continue }; if c.is_player() || c.dead { continue }; if !scope.covers(c.instance_id) { /// continue }` into one check — every call site still increments its own `visited` counter only on /// `Some`, matching the existing "gate first, then count" order everywhere. diff --git a/module/src/debug/encounter.rs b/module/src/debug/encounter.rs index 3d50e35a8..ae0a32438 100644 --- a/module/src/debug/encounter.rs +++ b/module/src/debug/encounter.rs @@ -133,7 +133,7 @@ pub fn debug_encounter_reset_hp_fired( /// Sweep EVERY kernel row for an instance (`encounter::sweep_encounter_state`) — kept as the /// narrow kernel-only lever; the FULL instance reap (which calls this sweep as its 228 splice) -/// is `debug_reap_instance` (`debug/instance.rs`, 190 slice 3 landed). +/// is `debug_reap_instance` (`debug/instance.rs`). #[reducer] pub fn debug_sweep_encounter_state(ctx: &ReducerContext, instance_id: u64) -> Result<(), String> { crate::helpers::require_operator(ctx)?; diff --git a/module/src/debug/instance.rs b/module/src/debug/instance.rs index 93ccebe7c..88024db34 100644 --- a/module/src/debug/instance.rs +++ b/module/src/debug/instance.rs @@ -11,7 +11,7 @@ use crate::{ game_gameobject, game_instance, game_world_entity, CreatureSpawn, ServerConfig, }; -/// THE FIXTURE DUNGEON (190's done-when, headless, zero imports): resolve-or-create an instance of +/// THE FIXTURE DUNGEON (headless, zero imports): resolve-or-create an instance of /// the character's CURRENT map through the REAL production path (`resolve_or_create_instance`: /// own binding → party's live instance → create, 5-cap enforced) and same-map-teleport them into /// it in place. On the dev map this instances the SEEDED world — the chicken/wolf/trainer roster @@ -92,7 +92,7 @@ pub fn debug_enter_instance( Ok(()) } -/// Force-reap an instance NOW (the full slice-3 teardown: population → encounter sweep → tick row +/// Force-reap an instance NOW (the full teardown: population → encounter sweep → tick row /// → bindings → row) without waiting out the 30min empty window. It refuses the same live-player, /// pending-intent, and Escrow claims as the scheduled reaper. The headless reap-verification lever. #[reducer] diff --git a/module/src/debug/mod.rs b/module/src/debug/mod.rs index 804d854f4..3f2c4bbf9 100644 --- a/module/src/debug/mod.rs +++ b/module/src/debug/mod.rs @@ -455,8 +455,8 @@ pub fn debug_force_cast_at( /// (`stats::max_health_for`/`max_power_for`, the SAME helpers `player_login` uses — not reimplemented). /// Health/power are refilled to the new max for the test. Also recomputes the five base attributes so /// the character sheet stays consistent with the new level. Thin wrapper over the shared -/// `stats::set_character_level` core factored it out so `gm::gm_command`'s `.level` -/// reuses the SAME recompute — see that fn's doc). +/// `stats::set_character_level` core, which `gm::gm_command`'s `.level` also uses, so both run the +/// SAME recompute (see that fn's doc). #[reducer] pub fn debug_set_level( ctx: &ReducerContext, @@ -811,7 +811,7 @@ pub fn debug_spawn_gameobject( state: 0, created_at: ctx.timestamp, respawn_at_micros: 0, // a freshly-spawned node is ready (no pending respawn) - instance_id: 0, // debug spawns land in the open world (190 slice 2) + instance_id: 0, // debug spawns land in the open world grid_x: lyracore_shared::spatial::grid_cell(x, y).0, grid_y: lyracore_shared::spatial::grid_cell(x, y).1, cell: lyracore_shared::spatial::cell_id_at(x, y), @@ -965,7 +965,7 @@ pub fn debug_setup_gather_pool( /// Force a pool RE-ROLL NOW for a deterministic no-wait verify (the ~4s sense tick would do the same on /// the armed timer). Deactivates `gathered_guid` and activates a different inactive weighted-chosen /// point — the active count stays `max_active`. `gathered_guid` is the live guid of the point to roll; -/// resolve it from the SQL `SELECT guid FROM game_gameobject WHERE...` (it's < 2^53 only for small +/// resolve it from the SQL `SELECT guid FROM game_gameobject WHERE ...` (it's < 2^53 only for small /// point_ids — pass it after a `--` if negative-looking, though GO guids are large/unsigned). #[reducer] pub fn debug_force_pool_reroll( @@ -1103,8 +1103,8 @@ pub fn debug_begin_cast( ) } -/// Cast a GROUND-TARGETED spell at an explicit world point by caster guid — server-side proof of the 118 -/// dest plumbing (the CLI owns no entity, so it can't drive the real `cast_spell_at`). Drives the +/// Cast a GROUND-TARGETED spell at an explicit world point by caster guid — server-side proof of the +/// ground-target dest plumbing (the CLI owns no entity, so it can't drive the real `cast_spell_at`). Drives the /// full `begin_cast(Some(dest))` path, so a TIMED ground spell also exercises the PendingCast dest carry. #[reducer] pub fn debug_cast_spell_at( @@ -1544,7 +1544,7 @@ pub fn debug_skin_nearest(ctx: &ReducerContext, character_guid: u64) -> Result<( let looter = crate::helpers::live_entity(ctx, character_guid) .map_err(|_| format!("skinner {character_guid} not in world"))?; // A skinnable target: a dead non-player BEAST corpse not yet skinned, in the looter's OWN - // (map, instance) partition (190 slice 2 — mirrors `loot::can_skin`'s live gate, so the + // (map, instance) partition (mirrors `loot::can_skin`'s live gate, so the // debug lever can never find a corpse the real skin path would refuse). Same-partition scan // via `crate::helpers::nearest_entity`. let corpse_guid = crate::helpers::nearest_entity(ctx, &looter, |e| { @@ -1830,25 +1830,26 @@ pub fn debug_reset_talents( crate::talent::do_reset_talents(ctx, character_guid, trainer_guid).map(|_| ()) } -/// Floor for a dedicated per-instance tick interval's honesty addendum: every firing -/// is a transaction on the ONE serialized commit stream — 10 instances at 100ms is already 100 -/// extra transactions/sec preempting player actions; anything tighter than 50ms is a foot-gun with -/// no gameplay payoff at 1.12 animation cadences). +/// Floor for a dedicated per-instance tick interval. Every firing is a transaction on the ONE +/// serialized commit stream: 10 instances at 100ms is already 100 extra transactions/sec preempting +/// player actions. Anything tighter than 50ms has no gameplay payoff at 1.12 animation cadences. const INSTANCE_TICK_MS_FLOOR: u64 = 50; +/// Ceiling for `debug_arm_instance_tick`: bounds `tick_ms` so `tick_ms * 1000` never overflows into +/// a negative interval, which would refire continuously. const INSTANCE_TICK_MS_CEIL: u64 = 600_000; /// Arm (or re-arm) a DEDICATED creature-tick row for `instance_id` at `tick_ms`: that /// instance's creature passes then fire on THIS row at its own cadence, and the catch-all row skips /// the instance (coverage is a partition — see `TickScope` in creatures/ai.rs). The global due-time /// passes (decay/respawn/regen/combat-drop) STAY on the catch-all row for all instances, so this knob -/// only smooths movement/AI latency — per the item's honest bound it buys NO parallel throughput and +/// only smooths movement/AI latency. It buys NO parallel throughput, and /// each firing taxes the shared serialized commit stream; use tight cadences sparingly and read the /// once-a-minute "pass rows-visited" log line to see what each row actually scans. /// -/// 190 slice 2 LANDED: `instance::create_instance` inserts exactly this row shape (500ms default) -/// and the instance reap (`instance::teardown_instance`) deletes it — `debug_disarm_instance_tick`'s -/// body. This reducer remains the operator RETUNE lever for a live instance's cadence. +/// `instance::create_instance` inserts exactly this row shape (500ms default) and +/// `instance::teardown_instance` deletes it, as `debug_disarm_instance_tick` does. This reducer is +/// the operator RETUNE lever for a live instance's cadence. /// Idempotent per instance (replaces any existing dedicated row). #[reducer] pub fn debug_arm_instance_tick( @@ -1888,7 +1889,7 @@ pub fn debug_arm_instance_tick( /// Remove the dedicated creature-tick row for `instance_id`, coverage of that /// instance returns to the catch-all row on its next firing (the `TickScope` rebuild is per firing), /// so its creatures keep ticking at the global 0.5s cadence; nothing is ever stranded. This is also -/// the future instance-reap's tick-row cleanup (190 slice 2). +/// the future instance-reap's tick-row cleanup. #[reducer] pub fn debug_disarm_instance_tick(ctx: &ReducerContext, instance_id: u64) -> Result<(), String> { if instance_id == crate::creatures::GLOBAL_TICK_INSTANCE { @@ -1952,7 +1953,7 @@ pub fn debug_verify_combat_regen(ctx: &ReducerContext, character_guid: u64) -> R } /// Cast `spell_id` as `character_guid` THROUGH the spellbook gate (`knows_spell`) — the by-guid mirror of -/// the sender-bound `cast_spell`, so a test can verify the learnable-spell gate (rank 27): the cast is +/// the sender-bound `cast_spell`, so a test can verify the learnable-spell gate: the cast is /// rejected until the spell is in the baseline kit OR learned (e.g. via an ability talent), then accepted. /// Unlike `debug_cast_at` (which drives `resolve_cast_at` and BYPASSES the gate), this exercises the gate. #[reducer] @@ -2020,7 +2021,7 @@ pub fn debug_seed_scenario_fixtures(ctx: &ReducerContext) { // row_count: quest 50900 (1) + how many of the 3 scenario NPC templates (questgiver 51003, // vendor 51004, weapon master 51005) are present after this call — an anchor-row proxy for the // fixture's full spread across quest/text/objective/reward/vendor/trainer tables, not an exact - // total (provenance stamp). + // total. let quest = ctx .db .game_quest_template() diff --git a/module/src/encounter.rs b/module/src/encounter.rs index d5da90ccb..d81d15973 100644 --- a/module/src/encounter.rs +++ b/module/src/encounter.rs @@ -25,10 +25,9 @@ //! leg through `creatures::tick::emit_move_spline`; the creature tick advances the stored //! position), and [`encounter_reset`]. //! -//! Reset/sweep: 190 slice 3 (instance reap) deletes per-instance kernel state via +//! Reset/sweep: the instance reap deletes per-instance kernel state via //! [`sweep_encounter_state`], called from `instance::teardown_instance_inner` right before the -//! `game_instance` row itself goes (the splice point is documented on the fn, mirroring 229's -//! `debug_arm_instance_tick` splice notes). [server] +//! `game_instance` row itself goes (the splice point is documented on the fn). [server] use spacetimedb::{table, ReducerContext, Table}; @@ -243,7 +242,7 @@ pub const HP_FIRED_NONE: u32 = 101; /// PK-pair pattern; see `game_player_reputation`). A missing row reads as /// (`ENCOUNTER_NOT_STARTED`, payload 0). `encounter_id`s with [`RESERVED_ENCOUNTER_ID_BIT`] set /// are kernel bookkeeping rows (HP dedup), not package encounters. Swept per instance by -/// [`sweep_encounter_state`] (190 slice 3). [server] +/// [`sweep_encounter_state`]. [server] #[table(accessor = game_encounter_state, index(accessor = by_pair, btree(columns = [instance_id, encounter_id])))] pub struct EncounterState { #[primary_key] @@ -818,11 +817,10 @@ fn despawn_tracked(ctx: &ReducerContext, tracked: &[EncounterSpawn]) { /// `game_encounter_state` rows (package encounters AND the reserved HP fired-marks), and all /// equip rows. HP WATCHES are global template config and deliberately survive. /// -/// SPLICE POINT (190 slice 3, instance reap — mirrors 229's `debug_arm_instance_tick` splice -/// notes): the reap's population sweep calls `crate::encounter::sweep_encounter_state(ctx, -/// instance_id)` right next to its entity/corpse/GO-copy deletes, before the `game_instance` row -/// itself goes (`instance::teardown_instance_inner`). Also exercised directly by the debug-feature -/// lever `debug_sweep_encounter_state`. +/// SPLICE POINT: the instance reap's population sweep calls +/// `crate::encounter::sweep_encounter_state(ctx, instance_id)` right next to its entity/corpse/GO-copy +/// deletes, before the `game_instance` row itself goes (`instance::teardown_instance_inner`). Also +/// exercised directly by the debug-feature lever `debug_sweep_encounter_state`. pub(crate) fn sweep_encounter_state(ctx: &ReducerContext, instance_id: u64) { let tracked: Vec = ctx .db diff --git a/module/src/exploration.rs b/module/src/exploration.rs index 8f0120a9b..56e41f886 100644 --- a/module/src/exploration.rs +++ b/module/src/exploration.rs @@ -1,5 +1,4 @@ -//! Exploration / discovery XP; the "Discovered: " toast + map fog have -//! landed). Entering a subzone for the FIRST time awards discovery XP and sends TWO packets — this is +//! Exploration / discovery XP (the "Discovered: " toast + map fog). Entering a subzone for the FIRST time awards discovery XP and sends TWO packets — this is //! retail parity, not a duplicate (see `check_area_exploration`'s doc comment for the //! cited source): `SMSG_EXPLORATION_EXPERIENCE` (the toast + fog-clear, off the //! `game_character_explored` insert) and `SMSG_LOG_XPGAIN` (the "+N experience" text, off a non-kill diff --git a/module/src/gameobject.rs b/module/src/gameobject.rs index 6f9532309..97d661b61 100644 --- a/module/src/gameobject.rs +++ b/module/src/gameobject.rs @@ -222,7 +222,7 @@ pub struct GameObject { /// column of that index is matched by an equality term, and it skips any index with more than 3 /// columns outright (`MAX_EXACT_INDEX_COLS`); range predicates are never index-served at all /// (`IndexProbe::Range` — "we currently never construct this variant") and an `OR` is evaluated - /// row-by-row. So a `grid_x BETWEEN.. AND grid_y BETWEEN..` box degrades to a full partition + /// row-by-row. So a `grid_x BETWEEN .. AND grid_y BETWEEN ..` box degrades to a full partition /// scan — 1.1 BILLION rows examined on `game_gameobject` in a 445-player measurement. Folding the /// two grid columns into one makes `by_cell` a 3-column all-equality index, which the planner CAN /// serve, and the AOI box becomes 25 point probes instead of a scan. @@ -327,7 +327,7 @@ pub struct GameObjectPool { } /// A pool member POINT (cmangos `pool_gameobject` subset). A *potential* spawn location: a point is -/// TIVE iff a `game_gameobject` row exists at `pool_point_guid(point_id)`, INACTIVE otherwise, the +/// ACTIVE iff a `game_gameobject` row exists at `pool_point_guid(point_id)`, INACTIVE otherwise, the /// active/inactive distinction is the EXISTENCE OF A ROW, not a column (so the spawn row is untouched). /// `template_entry` chooses which GO spawns here (a higher-tier template = a rarer member); `weight` is /// the relative selection weight (0 = never auto-selected). Public + SQL-loadable (no Timestamp). @@ -441,7 +441,7 @@ pub(crate) fn reroll_pool(ctx: &ReducerContext, pool_id: u32, gathered_guid: u64 activate_point(ctx, chosen); } -/// TIVATE a pool point, insert its READY `game_gameobject` row at the derived guid. Shared by +/// ACTIVATE a pool point, insert its READY `game_gameobject` row at the derived guid. Shared by /// `arm_pool` (initial fill) and `reroll_pool` (rotation). Idempotent-by-guid: the caller guarantees /// the point is currently inactive (no row at its guid), so this is a plain insert. fn activate_point(ctx: &ReducerContext, m: &GameObjectPoolMember) { @@ -456,7 +456,7 @@ fn activate_point(ctx: &ReducerContext, m: &GameObjectPoolMember) { state: 0, created_at: ctx.timestamp, respawn_at_micros: 0, - instance_id: 0, // pools are open-world machinery (190 slice 2: never instanced), + instance_id: 0, // pools are open-world machinery (never instanced) grid_x: lyracore_shared::spatial::grid_cell(m.x, m.y).0, grid_y: lyracore_shared::spatial::grid_cell(m.x, m.y).1, cell: lyracore_shared::spatial::cell_id_at(m.x, m.y), @@ -676,7 +676,7 @@ pub(crate) fn usable_go( Ok((go, tmpl)) } -/// Pick the lock on a locked GameObject, Pick Lock 1804, gateway-intercepted by the +/// Pick the lock on a locked GameObject (Pick Lock 1804, gateway-intercepted by the /// E_OPEN_LOCK effect kind). Gated same-map/instance + range like [`apply_use_gameobject`] (the caster /// walked up to click it). Reads the GO template's `lock_id`, then scans `game_lock` for a SATISFIABLE /// real opener — a SKILL row (kind 2, `property != 0`) the caster's `game_player_skill(property)` meets, @@ -765,7 +765,7 @@ pub(crate) fn apply_use_gameobject( caster_guid: u64, go_guid: u64, ) -> Result<(), String> { - // Map + instance gated (190 slice 2 — GO rows carry `instance_id` now): a player may only use + // Map + instance gated (GO rows carry `instance_id`): a player may only use // a GO in THEIR OWN instance (dungeon doors/chests are per-instance copies; the static // instance-0 originals on a dungeon map are the copy SOURCES, unreachable from inside a run). let (go, tmpl) = usable_go(ctx, caster_guid, go_guid).map_err(String::from)?; @@ -780,7 +780,7 @@ fn use_resolved_gameobject( ) -> Result<(), String> { // Snapshot for the on_go_used notify hook fired at the success exit below — `go` is moved into // an update (or deleted by a pool reroll) inside the dispatch arms. The instance is the GO - // ROW'S OWN (190 slice 2, per 228's documented splice note; equal to the user's after the gate + // ROW'S OWN (equal to the user's after the gate // above, but the row is the authoritative source). let (used_go_guid, used_go_entry, used_go_instance) = (go.guid, go.template_entry, go.instance_id); @@ -864,7 +864,7 @@ fn use_resolved_gameobject( // // (1) ROAMING pool (a pooled point whose pool is `in_place == false`) → the rotation IS the // respawn: `reroll_pool` NOW (delete this point, activate a DIFFERENT inactive one) and RETURN - // , no state flip, no armed timer (reroll_pool deleted the row, so pass_gameobject_respawn + // — no state flip, no armed timer (reroll_pool deleted the row, so pass_gameobject_respawn // never sees this guid → no double-respawn). Deleting the point relays SMSG_DESTROY_OBJECT // (vanishes on gather = correct feedback) and the new point's INSERT relays CREATE_OBJECT (the // node roams visible now). Active count stays max_active. DEBUG-ONLY: vanilla Elwynn is not @@ -920,7 +920,7 @@ fn use_resolved_gameobject( // `on_go_update` relay (subscriptions.rs) that re-emits it live, mirroring the corpse // body→bones re-emit. LOCK gate: a DOOR/BUTTON whose lock has a REAL opener // (`locked_shut`) refuses the toggle until Pick Lock records a `game_gameobject_unlocked` - // row. lock_id 0 / a hand-open (property==0-only) lock toggles freely (unchanged, per 211). + // row. lock_id 0 / a hand-open (property==0-only) lock toggles freely (unchanged). if locked_shut(ctx, go.guid, tmpl.lock_id) { return Err("it is locked".to_string()); } @@ -1097,14 +1097,12 @@ pub fn debug_pick_lock_entry( /// /// `packed`: rows separated by `;`, fields by `,`, in the order /// `guid,template_entry,map_id,x,y,z,orientation,initial_state,rot0,rot1,rot2,rot3`. `created_at` = -/// `ctx.timestamp`. `initial_state`, END field, widened alongside -/// `import_gameobjects_append` in the SAME commit, not a table migration: this is a string protocol -/// between the importer and this reducer, not a reducer arg) lets a DOOR/BUTTON spawn already-open -/// (cmangos `startOpen`); every other type's importer row carries 0 (ready/closed), byte-identical to -/// the pre-211 always-0 shape. `rot0..3` (END fields — the cmangos spawn quaternion) ride -/// AFTER `initial_state` rather than beside `orientation` so every pre-515 packed-row builder in this -/// file's tests keeps working unmodified; a hand-built row that omits them fails loudly (field-count -/// check below) rather than silently importing a zero quaternion. +/// `ctx.timestamp`. `initial_state` is an END field of the string protocol between the importer and +/// this reducer, not a table migration or a reducer arg. It lets a DOOR/BUTTON spawn already-open +/// (cmangos `startOpen`); every other type's importer row carries 0 (ready/closed). `rot0..3` (END +/// fields, the cmangos spawn quaternion) ride AFTER `initial_state` rather than beside `orientation`, +/// so older packed-row builders in this file's tests keep working unmodified. A hand-built row that +/// omits them fails loudly (field-count check below) rather than silently importing a zero quaternion. #[reducer] pub fn import_gameobjects(ctx: &ReducerContext, packed: String) -> Result<(), String> { crate::helpers::require_operator(ctx)?; @@ -1160,7 +1158,7 @@ fn load_go_batch(ctx: &ReducerContext, packed: &str) -> Result { state: pu8(f[7])?, created_at: now, respawn_at_micros: 0, // a freshly-imported node is ready (no pending respawn) - instance_id: 0, // imported static rows are open-world (dungeon copies are runtime, 190 slice 2) + instance_id: 0, // imported static rows are open-world (dungeon copies are runtime) grid_x: lyracore_shared::spatial::grid_cell(gx_src, gy_src).0, grid_y: lyracore_shared::spatial::grid_cell(gx_src, gy_src).1, diff --git a/module/src/gc.rs b/module/src/gc.rs index f7ebf9b10..5c33ae986 100644 --- a/module/src/gc.rs +++ b/module/src/gc.rs @@ -68,15 +68,11 @@ pub fn reap_movement_events(ctx: &ReducerContext, _schedule: EventReaperSchedule } }}; } - // `game_movement_event` is GONE (dropped): peer movement rides the per-mover - // `game_entity_motion` row, which is updated in place and needs no reaping at all (perf catalog - // 2.1). This reap was the second half of the O(C²) cost: at 200 co-located players it deleted - // 67,753 rows/s to match the 70,568 the writer had just inserted. - // `game_creature_move_event` is NO LONGER REAPED because nothing writes it any more — creature - // legs ride the per-creature `game_creature_spline` row, which is updated in place and needs no - // reaping (same move as perf catalog 2.1 made for player movement). This reap was deleting - // 121.6 rows/s at 100 dispersed players to match the inserts. The table stays in the schema - // (dropping one is a separate destructive migration) and stays empty. + // `game_movement_event` is not reaped: peer movement rides the per-mover `game_entity_motion` + // row, which is updated in place and needs no reaping. + // `game_creature_move_event` is not reaped because nothing writes it. Creature legs ride the + // per-creature `game_creature_spline` row, which is updated in place. The table stays in the + // schema (dropping one is a separate destructive migration) and stays empty. reap!(game_combat_event); // melee swing logs reap!(game_xp_event); // per-kill XP awards reap!(game_levelup_event); // level-up dings diff --git a/module/src/gm.rs b/module/src/gm.rs index 47892d446..431c8797d 100644 --- a/module/src/gm.rs +++ b/module/src/gm.rs @@ -278,9 +278,9 @@ pub(crate) fn parse_gm_command(text: &str) -> Result { // Reducers // =========================================================================================== -/// Grant/revoke GM level (operator-only CLI reducer, authz): `level` gates +/// Grant/revoke GM level (operator-only CLI reducer): `level` gates /// [`gm_command`] (`0` = no access — the moderation-facing per-level distinctions beyond "has -/// access at all" are's concern, not this one's). Looked up by character NAME +/// access at all" are out of scope). Looked up by character NAME /// (case-insensitive, as `/w bob` reaches Bob) since an operator drives this via /// `spacetime call`, which has no live guid to hand. `require_operator` gates it exactly like the /// importer reducers — never player-callable. @@ -398,7 +398,7 @@ pub(crate) fn apply_gm_command( // `map_id` comes from the destination, so a cross-map `.tele` (ValleyOfTrials, map 1) // rides the same primitive and becomes a full escrowed transfer to whichever database // owns that map. Deadmines is still deliberately absent: map 36 has no imported world - // data, so it would.tele into an empty room. + // data, so it would .tele into an empty room. crate::world::teleport_player(ctx, caller.guid, map_id, 0, x, y, z, o); } } diff --git a/module/src/graveyard.rs b/module/src/graveyard.rs index 3807e0719..26e65f969 100644 --- a/module/src/graveyard.rs +++ b/module/src/graveyard.rs @@ -29,7 +29,7 @@ pub struct GraveyardLoc { /// One cmangos `game_graveyard_zone` row — links a `game_graveyard.id` (`safe_loc_id`) to the zone /// it serves, with an optional faction restriction (0 = both factions; else the cmangos team-faction /// id, see `team_for_race`). Imported by the importer's `--dump` mode; see -/// `importer/src/main.rs::build_graveyard_zone_sql`); `resolve_graveyard` reads it via `by_zone` to +/// `importer/src/main.rs::build_graveyard_zone_sql`. `resolve_graveyard` reads it via `by_zone` to /// prefer a zone-linked graveyard over a merely-closer unlinked one (the cmangos release rule). No /// Timestamp → plain SQL. [static] #[table( @@ -83,7 +83,7 @@ const EASTVALE: Graveyard = Graveyard { }; // world_safe_locs id ≈80 [V], Sentinel Hill (Westfall, zone 40). Hand-added for -// (the Westfall 1-20 slice); coords are an UNVERIFIED estimate (this sandbox has no reference +// the Westfall 1-20 slice; coords are an UNVERIFIED estimate (this sandbox has no reference // world-database to read the real row from); orientation defaults to 0.0 (not sourced). CONFIRM // x/y/z against your own imported world_safe_locs before relying on this for a live release. const SENTINEL_HILL: Graveyard = Graveyard { @@ -213,7 +213,7 @@ fn zone_linked( /// Every imported graveyard on `map_id`, regardless of zone — the fallback once zone-scoping /// isn't available or didn't resolve (see `resolve_graveyard`). Map-only — same RESOLVED note -/// as `zone_linked` (190 slice 2): only non-instance-map releases ever reach this chain. +/// as `zone_linked`: only non-instance-map releases ever reach this chain. fn all_on_map(ctx: &ReducerContext, map_id: u32) -> Vec { ctx.db .game_graveyard() diff --git a/module/src/group.rs b/module/src/group.rs index dc25a4e3a..2e527217d 100644 --- a/module/src/group.rs +++ b/module/src/group.rs @@ -449,10 +449,10 @@ pub(crate) fn push_event( other_guid: u64, payload: String, ) { - // No longer an early return on a missing character row. On a world shard the row is there - // and this is byte-identical to before; on REALM-CORE there are no character rows at all, and - // returning early there would mean the directory database could never notify anybody — every - // invite popup and roster refresh for a cross-shard party would be dropped at the source. + // A missing character row is not an early return. A world shard has the row. REALM-CORE has no + // character rows at all, so an early return there would stop the directory database from + // notifying anybody, and every invite popup and roster refresh for a cross-shard party would be + // dropped at the source. let bound = ctx .db .game_character() @@ -1091,7 +1091,7 @@ fn led_group_of(ctx: &ReducerContext, guid: u64) -> Result<(GroupMember, Group), Ok((m, group)) } -/// `pub(crate)` (was private):'s kill-time loot stamping (`combat/mod.rs` → +/// `pub(crate)` because the kill-time loot stamping (`combat/mod.rs` → /// `loot::apply_group_loot_rules`) needs the CURRENT roster to pick a round-robin/master designee. pub(crate) fn members_of(ctx: &ReducerContext, group_id: u64) -> Vec { ctx.db diff --git a/module/src/gw.rs b/module/src/gw.rs index d592d74c9..55f8e6fcb 100644 --- a/module/src/gw.rs +++ b/module/src/gw.rs @@ -17,7 +17,7 @@ use crate::helpers::{acting_entity_by_guid, require_operator}; // `client_disconnected` instead of one per player, so nothing per-player tears world entities // down and a crash would leave every seated player as a permanent world ghost. The fix is a // LEASE: each gateway heartbeats its row; sessions opened through the shared connection bind -// their entity to the lease (stage 4d writes `GatewaySession` rows at `gw_player_login`); a +// their entity to the lease (`gw_player_login` writes the `GatewaySession` rows); a // scheduled reaper removes the world entities of any lease that stops heartbeating. // // Deliberately TTL-ONLY — no sweep on the shared connection's own disconnect. The coordinator @@ -38,7 +38,7 @@ pub struct GatewayLease { } /// entity_guid → lease binding for a session riding the shared connection. Written by -/// `gw_player_login` (stage 4d); until then the table is empty and the reaper below is inert. +/// `gw_player_login`; the reaper below is inert while the table is empty. /// Private. [server] #[table(accessor = game_gateway_session)] pub struct GatewaySession { @@ -668,7 +668,7 @@ pub fn gw_send_emote( crate::chat::apply_send_emote(ctx, sender, text_emote, emote_anim, target_guid) } -/// The shared-connection login (stage 4d): enter the world with the account named by id and +/// The shared-connection login: enter the world with the account named by id and /// the session bound to this gateway's lease. Delegates to the same [`crate::world::apply_player_login`] /// core the sender path uses; the OWNER identity stamped onto the entity and the character's /// owner-RLS rows is the account's BOUND identity (from `establish_session`), so a per-player diff --git a/module/src/helpers.rs b/module/src/helpers.rs index 493f47a6b..643c1f759 100644 --- a/module/src/helpers.rs +++ b/module/src/helpers.rs @@ -51,7 +51,7 @@ pub fn entity_by_owner(ctx: &ReducerContext, owner: Identity) -> Option(ctx: /// &ReducerContext, guid: u64, identity: Identity)`. /// -/// `character_owned!(transfer, fn (, , ) { table =.., by =.., .. })` and +/// `character_owned!(transfer, fn (, , ) { table = .., by = .., .. })` and /// `character_owned!(not_transported, fn ())` — the CROSS-DATABASE row transport, /// which is what lets a table's rows actually leave one SpacetimeDB database and arrive in /// another. Both expand to `pub(crate) fn (ctx: &ReducerContext, guid: u64, io: &mut @@ -216,8 +216,8 @@ macro_rules! game_tick_pass { /// /// Handlers OBSERVE and may act through the same reducer-internal fns core code uses; they must not /// assume any ordering relative to other handlers of the same event beyond the registry's -/// deterministic (sorted-by-path) order. Mutating/decorator semantics (veto, fold) are explicitly -/// and NOT this mechanism. +/// deterministic (sorted-by-path) order. Mutating or decorating semantics (veto, fold) are not +/// supported by this mechanism. #[macro_export] macro_rules! game_hook { ($event:ident, fn $name:ident($ctx:ident, $payload:ident) $body:block) => { @@ -272,7 +272,7 @@ macro_rules! encounter_package { include!(concat!(env!("OUT_DIR"), "/character_sweeps.rs")); // GENERATED by module/build.rs from packages/*/src/mod.rs discovery: one -// `#[path =...] pub mod pkg_;` per drop-in package, compiling the package's Rust into this +// `#[path = ...] pub mod pkg_;` per drop-in package, compiling the package's Rust into this // crate. Empty when packages/ holds no Rust packages. include!(concat!(env!("OUT_DIR"), "/package_mods.rs")); @@ -404,7 +404,7 @@ mod region; mod reputation; mod rest; // The Runtime Script Host. Called from `script_binding`'s dispatch on every hook event in a -// default build, so it no longer needs the "no caller yet" allow the host shipped with. +// default build. mod runtime_script; /// The `game_script` table and the Event Binding dispatch: which Runtime Scripts run for which /// event. Not re-exported below, for the same reason `package_import` is not — nothing outside this diff --git a/module/src/loot/mod.rs b/module/src/loot/mod.rs index 544f4f760..8480c3df7 100644 --- a/module/src/loot/mod.rs +++ b/module/src/loot/mod.rs @@ -53,7 +53,7 @@ pub struct CorpseLoot { pub slot: u8, // loot-window slot index (0-based) pub item_entry: u32, // -> game_item_template.entry pub count: u32, - // END-APPENDED (fixing 210's recorded divergence, module doc decision): + // END-APPENDED (see the module doc): // does this row carry a QUEST-only item (rolled from a `quest_only` creature/pickpocket/ // gameobject-loot row)? Quest rows now roll UNCONDITIONALLY (the drop-chance still applies; only // the pre-roll killer gate is gone) — visibility (gateway) and takability (`apply_take_loot`) are @@ -63,10 +63,10 @@ pub struct CorpseLoot { pub quest_only: bool, // END-APPENDED: `0` = the SHARED, not-yet-split row, any currently-needing // character may claim it, which triggers the per-member clone (`clone_quest_loot_for_group`); - // nonzero = a character guid, this specific row is a pernted the moment the shared row is first taken). Meaningless when `quest_only` is - // false UNLESS it's a group-loot WINNER-LOCKED row (below), GENERALIZED slices - // 2-4): nonzero now also marks "this row belongs EXCLUSIVELY to this guid" for a NEED/GREED roll - // winner whose bag was full at grant time (`resolve_roll`'s inventory-full fallback — the item + // nonzero = a character guid, this specific row is a per-member CLONE reserved for exactly that + // character (minted the moment the shared row is first taken). Meaningless when `quest_only` is + // false UNLESS it's a group-loot WINNER-LOCKED row (below): nonzero then also marks + // "this row belongs EXCLUSIVELY to this guid" for a NEED/GREED roll winner whose bag was full at grant time (`resolve_roll`'s inventory-full fallback — the item // sits here until they free space and re-loot). Same predicate on both the gateway's visibility // read and `apply_take_loot`'s gate either way: nonzero `reserved_for` always means "only this // guid, unconditionally". `#[default(0)]` — additive. @@ -116,7 +116,7 @@ pub struct CreatureLoot { pub group_id: u32, // 0 = independent roll; >0 = pick-one group (≤1 member drops per kill) // END-APPENDED: cmangos `ChanceOrQuestChance < 0`, the item is a QUEST-drop, only // ever visible to a player who currently needs it (an active matching COLLECT_ITEM objective — see - // `killer_needs_item`). Gated at ROLL TIME against the CREDITED KILLER (module doc, decision), + // `killer_needs_item`). Gated at ROLL TIME against the CREDITED KILLER (see the module doc), // never re-evaluated later. `#[default(false)]` so existing rows (imported pre-210, or seeded) keep // rolling exactly as before (never quest-gated) — additive auto-migration. #[default(false)] @@ -362,7 +362,7 @@ pub(crate) fn killer_needs_item(ctx: &ReducerContext, killer: Option, item: } /// Purge every `game_corpse_loot` / `game_corpse_loot_eligible` row still parked on `guid`, BEFORE a -/// fresh roll ever writes to it. Two residue sources land here: (a)'s corpse-guid REUSE +/// fresh roll ever writes to it. Two residue sources land here: (a) corpse-guid REUSE /// (a harness SQL teardown or `debug_spawn_at_feet` skipping the decay reaper leaves a departed kill's /// `game_corpse_loot_eligible` snapshot behind for the next creature minted at that guid), and (b) /// the pickpocket/kill collision — `roll_pickpocket_loot` inserts `game_corpse_loot` rows at @@ -480,8 +480,8 @@ pub(crate) fn insert_corpse_rows( /// Roll a creature's loot table into `game_corpse_loot` rows on its corpse; returns whether anything /// dropped (so the caller sets `UNIT_DYNFLAG_LOOTABLE`). Data-driven: reads `game_creature_loot` for /// `creature_entry`, then rolls independent rows + one weighted pick per group with the -/// `roll_loot_rows_quest_aware` core: `quest_only` rows roll UNCONDITIONALLY -/// now — no killer gate — the flag just rides along onto the inserted row for the gateway/take-path to +/// `roll_loot_rows_quest_aware` core (`quest_only` rows roll UNCONDITIONALLY, with +/// no killer gate; the flag just rides along onto the inserted row for the gateway/take-path to /// gate per-viewer/per-taker instead), inserting the winners at sequential loot slots with a fresh /// `reserved_for = 0` (unclaimed). No table rows → nothing drops (no universal placeholder). Shared by /// the real killing blow (combat/) and the debug kill (debug.rs) so both produce identical loot. @@ -580,8 +580,8 @@ pub struct GameFishingLoot { /// Roll a creature's PICKPOCKET table into `game_corpse_loot` rows keyed on the LIVE target's guid, then /// refresh `UNIT_DYNFLAG_LOOTABLE` (`refresh_lootable`) so a still-alive-but-pickpocketed -/// creature shows the loot cursor if anything rolled. Quest-only rows roll UNCONDITIONALLY now -///, no rogue gate; the flag rides onto the row for per-viewer/per-taker gating +/// creature shows the loot cursor if anything rolled. Quest-only rows roll UNCONDITIONALLY +/// (no rogue gate; the flag rides onto the row for per-viewer/per-taker gating /// downstream). A no-row table (or an all-miss roll) inserts nothing and never touches the flag — the /// existing money-only path (spell/cast.rs) is unaffected either way. Called from `E_PICKPOCKET` /// (spell/cast.rs), inside the SAME `!target.pickpocketed` once-gate the copper roll already uses, so @@ -856,8 +856,8 @@ pub(crate) fn apply_loot_money( "Loot Source is not a corpse", )); } - // Map + instance gated (190 slice 2): a creature corpse is a `game_world_entity` row, so its - // `instance_id` came free with slice 1 — a looter can never reach across an instance wall. + // Map + instance gated: a creature corpse is a `game_world_entity` row, so its + // `instance_id` is already on it, so a looter can never reach across an instance wall. if corpse.map_id != looter.map_id { return Err(refused(LootRefusal::OutOfRange, "corpse on another map")); } @@ -935,7 +935,7 @@ pub(crate) fn apply_loot_money( /// Credit `share` copper to `recipient_guid`'s purse: the live `WorldEntity.money` if currently /// online (relayed to their own connection as `PLAYER_FIELD_COINAGE`), else the durable -/// `Character.money` row directly, an OFFLINE grouped recipient still gets paid; +/// `Character.money` row directly (an OFFLINE grouped recipient still gets paid; /// `build_player_entity` loads `character.money` back into the entity at their next login). Saturating, /// mirroring the looter's own transfer in [`apply_loot_money`]. No-op if the guid resolves to neither /// (a deleted character mid-flight) — never panics on a stale snapshot row. @@ -1092,7 +1092,7 @@ mod tests { // ---- LOOT-FAMILY COMPLETENESS ---- /// `needs_item_pure` — the pure decision behind `killer_needs_item`: a COLLECT_ITEM objective on an - /// TIVE quest matching `item` says yes; a KILL_CREATURE objective (wrong kind), a different item + /// ACTIVE quest matching `item` says yes; a KILL_CREATURE objective (wrong kind), a different item /// (wrong target), or an objective whose quest ISN'T in `active_quests` (a stale/rewarded quest's /// leftover objective row) all say no. #[test] @@ -1129,7 +1129,7 @@ mod tests { /// every pre-210 row keeps rolling unconditionally), and the new tables' row shapes construct with /// named fields exactly like `CreatureLoot` (a compile-time guard against a silently reordered / /// renamed column — the importer's positional SQL INSERT depends on this order matching its - /// column list verbatim). `CorpseLoot` now END-carries `quest_only`/`reserved_for` too (187 slice 0). + /// column list verbatim). `CorpseLoot` now END-carries `quest_only`/`reserved_for` too. #[test] fn new_loot_family_tables_construct_with_the_documented_shape() { let creature = CreatureLoot { diff --git a/module/src/loot/rolls.rs b/module/src/loot/rolls.rs index 5f1a1d15f..503f79263 100644 --- a/module/src/loot/rolls.rs +++ b/module/src/loot/rolls.rs @@ -28,7 +28,7 @@ pub struct CorpseLootEligible { /// A live NEED/GREED/NBG roll on one `game_corpse_loot` row. Private — the gateway never reads this /// (relayed via `game_group_event` instead, see the module doc above). `resolved` is the exactly-once -/// resolution guard (trap): set the moment `resolve_roll` decides an outcome, checked +/// resolution guard: set the moment `resolve_roll` decides an outcome, checked /// FIRST on every entry point (the deadline sweep AND a landing vote can both reach the same roll). /// [entity] #[derive(Clone)] @@ -196,7 +196,7 @@ pub(crate) fn pick_roll_winner(rolls: &[u8]) -> Option { /// `combat::kill_creature` right after `roll_creature_loot`, for a grouped Loot Tag only. The /// corpse-eligibility rows are the one recipient set for designation, master loot, and rolls. /// -/// trap ("solo player with method GROUP set: threshold rows must NOT roll"): a solo +/// A solo player with method GROUP set must not roll threshold rows: a solo /// `recipients` (`len() < 2`, e.g. every other member out of XP range or dead) skips ALL group-loot /// handling too — vanilla's "party size 1 -> direct loot" applies to loot exactly like it does to /// the XP split, so the same recipient-count gate covers both. @@ -637,7 +637,7 @@ fn cleanup_roll(ctx: &ReducerContext, roll_id: u64) { ctx.db.game_loot_roll().id().delete(roll_id); } -/// Deadline sweep trap: "disconnected member mid-roll: their vote auto-passes at +/// Deadline sweep ("disconnected member mid-roll: their vote auto-passes at /// deadline"): scans every UNRESOLVED roll whose deadline has elapsed, auto-passes any still-`!voted` /// member (in place — never blocks), then resolves. `pub(crate)` — exposed for the orchestrator's /// scheduled-GC tick (`gc.rs`, NOT edited here per instructions): wire one call @@ -667,7 +667,7 @@ pub(crate) fn sweep_loot_rolls(ctx: &ReducerContext) { } } -/// trap ("disband mid-roll -> resolve to sole member"): called from +/// Disband mid-roll ("resolve to sole member"): called from /// `group::remove_member`'s full-disband branch with the FULL former membership (`member_guids`, /// leaver included) and the sole survivor if exactly one remains. Force-resolves every UNRESOLVED /// roll whose ENTIRE voter set belonged to the disbanding group: with a sole survivor, grants them diff --git a/module/src/motion.rs b/module/src/motion.rs index a87ac39f1..d9df99f05 100644 --- a/module/src/motion.rs +++ b/module/src/motion.rs @@ -118,7 +118,7 @@ pub struct MotionPublishSchedule { /// run with no start, or landing with no jump. /// /// This mirrors the gateway's own rule 1 (`world/coalesce.rs`): "any non-heartbeat -/// opcode is ALWAYS a state change"), one layer down — it is deliberately the SAME classification, +/// opcode is ALWAYS a state change", one layer down — it is deliberately the SAME classification, /// only stricter about `SET_FACING`, which the gateway forwards eagerly because the module's stored /// heading feeds server-side facing checks. pub(crate) fn is_discrete(opcode: u16) -> bool { diff --git a/module/src/operations.rs b/module/src/operations.rs index f1c1dcc63..6254169db 100644 --- a/module/src/operations.rs +++ b/module/src/operations.rs @@ -243,7 +243,7 @@ pub fn debug_repair_after_publish(ctx: &ReducerContext) -> Result<(), String> { )), }); - // Stage 4a: re-arm the gateway lease reaper (bounded ghost lifetime for sessions riding + // Re-arm the gateway lease reaper (bounded ghost lifetime for sessions riding // the shared connection). Rearm-not-ensure is safe here — unlike the motion tick there is no // operator tuning knob to preserve; the canonical interval is the only interval. let lease_sched = ctx.db.game_gateway_lease_reaper_schedule(); diff --git a/module/src/professions.rs b/module/src/professions.rs index d3ffcdc15..75ce4e8de 100644 --- a/module/src/professions.rs +++ b/module/src/professions.rs @@ -24,7 +24,7 @@ use crate::game_world_entity; use crate::loot::LOOT_RANGE_SQ; // =========================================================================================== -// SKINNING (professions slice 2) — a skill-gated corpse interaction, modeled on `loot_money` +// SKINNING — a skill-gated corpse interaction, modeled on `loot_money` // =========================================================================================== /// The leather a skin yields — the REAL vanilla "Light Leather" `game_item_template` TRADE_GOODS entry @@ -150,8 +150,8 @@ pub(crate) fn skin_corpse( looter.dead, corpse.is_player(), corpse.dead, - // Map + instance (190 slice 2): `can_skin`'s `same_map` conjunct now carries the - // instance-equality clause too — the corpse is a `game_world_entity` row, slice-1-tagged. + // Map + instance: `can_skin`'s `same_map` conjunct now carries the + // instance-equality clause too — the corpse is a `game_world_entity` row, instance-tagged. corpse.map_id == looter.map_id && corpse.instance_id == looter.instance_id, dist_sq, entry_is_beast(ctx, corpse.entry), @@ -409,7 +409,7 @@ pub(crate) fn apply_enchant_item( mod tests { use super::*; - // ---- SKINNING (professions slice 2) ---- + // ---- SKINNING ---- /// The SKIN gate: every reject condition fails with its reason, and ONLY an alive looter at a dead, /// in-range, same-map BEAST corpse that isn't already skinned proceeds (with sufficient Skinning skill). diff --git a/module/src/quest.rs b/module/src/quest.rs index aeefa14b2..1646ae03c 100644 --- a/module/src/quest.rs +++ b/module/src/quest.rs @@ -160,7 +160,7 @@ const PARTY_SHARE_RANGE_SQ: f32 = crate::group::GROUP_XP_RANGE_SQ; /// (`gateway/src/stdb/reads.rs`) — both sides model the same 20-slot vanilla quest log. pub(crate) const MAX_QUEST_LOG_SIZE: usize = 20; -/// Vanilla level cap (quest XP→money conversion gate,(e)), matches `xp::xp_to_next_level`'s +/// Vanilla level cap (quest XP→money conversion gate), matches `xp::xp_to_next_level`'s /// own hardcoded cap check (`level >= 60` there too; not re-exported as a shared const, so this is a /// second literal by necessity — pinned by a test alongside `xp::xp_to_next_level`'s own cap tests). const QUEST_MAX_LEVEL_PAYOUT: u32 = 60; @@ -231,7 +231,7 @@ pub struct QuestTemplate { #[default(false)] pub repeatable: bool, // --- forward chains + timed quests, END-appended, #[default(0)] → additive - // auto-migration) --- + // auto-migration --- // cmangos NextQuestId/NextQuestInChain: the successor quest auto-offered on this quest's turn-in // (0 = no successor). `prev_quest_id`/auto_finish already do the ACCEPT-side gating + the // "arm the accept button" wire bit (module/src/quest.rs's prereq check, gateway/src/codec/quest.rs's @@ -420,7 +420,7 @@ pub struct CharacterQuest { pub quest_entry: u32, // -> QuestTemplate.entry pub counts: Vec, // per-objective kill progress, indexed by QuestObjective.obj_index pub rewarded: bool, // true once turned in — the row stays to block a repeat - // --- timed quests, END-appended, #[default(...)] → additive auto-migration) --- + // --- timed quests, END-appended, #[default(...)] → additive auto-migration --- // Micros-since-epoch deadline stamped by `apply_accept_quest` from `QuestTemplate.limit_time` // (`ctx.timestamp` micros + `limit_time * 1_000_000`); 0 = untimed (the vast majority of quests — // existing rows default to this, unchanged behavior). Cleared back to 0 the instant the quest @@ -653,7 +653,7 @@ fn validate_giver( // Poster" starts q176 Wanted: Hogger (no creature start giver at all), and the GO 55/56 "Lost // Guards" corpses drive the q37/q45/q71 chain. Same map + range gate. if let Some(go) = ctx.db.game_gameobject().guid().find(giver_guid) { - // GameObject giver: map + instance gated (190 slice 2 — GO rows carry `instance_id` now; + // GameObject giver: map + instance gated (GO rows carry `instance_id`; // dungeon GOs are per-instance copies, so a giver in another party's Deadmines can never // serve this player). if go.map_id != player.map_id || go.instance_id != player.instance_id { @@ -1164,7 +1164,7 @@ pub(crate) fn request_turn_in_quest( lyracore_shared::quest::xp_reward(tmpl.quest_level) }; let xp = crate::xp::rated_xp(ctx, base_xp); - // Level-cap payout(e)): at the vanilla level cap, quest XP is USELESS (no next + // Level-cap payout: at the vanilla level cap, quest XP is USELESS (no next // level to fill) — real vanilla instead converts it to copper. Money-converted, never granted as // XP (vanilla behaviour: a capped character never dings off a quest). Below the cap, unchanged: XP // granted normally, no conversion. `max_level_money_reward` (the gateway's quest-log-preview stub, @@ -2787,7 +2787,7 @@ pub(crate) fn on_areatrigger_entered(ctx: &ReducerContext, player_guid: u64, tri /// Loaded by the importer's `--dump` "globals" family /// (`importer/src/main.rs::build_areatrigger_teleport_sql`). Module-only: deliberately NOT `public` — /// no client or gateway ever reads a teleport TARGET directly, only its EFFECT (the cross-map teleport -/// handshake,, which already carries its own `game_teleport_event` wire path), so this +/// handshake, which already carries its own `game_teleport_event` wire path), so this /// table needs no gateway binding at all (danger-zones.md §1's "new table → regenerate bindings" rule /// only applies to tables a gateway subscription reads). [`apply_enter_areatrigger`] looks this up by /// `trigger_id`; a hit routes the player through [`crate::world::teleport_player`], a miss leaves the @@ -3037,13 +3037,13 @@ pub(crate) fn enter_sessionless_areatrigger( } /// The shared core behind [`enter_areatrigger`] and `debug_enter_areatrigger`. Quest -/// credit fires FIRST and UNCONDITIONALLY (unchanged from pre-225 behavior), then — iff `trigger_id` -/// has an imported `game_areatrigger_teleport` row — the player is routed through 224's cross-map +/// credit fires FIRST and UNCONDITIONALLY (unchanged), then — iff `trigger_id` +/// has an imported `game_areatrigger_teleport` row — the player is routed through the cross-map /// teleport. A trigger can be BOTH a quest-explore objective AND a teleport (Deadmines' Moonbrook /// entrance/exit triggers, `[V]` ids ~1447/~1448 per the design doc) — this is deliberate, not a bug: /// the two arms are independent and both fire on the same `CMSG_AREATRIGGER`. /// -/// 190 slice 2 (the former "HOOK POINT"): a teleport whose `target_map` is a DUNGEON +/// Dungeon entry: a teleport whose `target_map` is a DUNGEON /// (`instance::is_dungeon_map` — Deadmines = map 36 today) resolves-or-creates the caller's /// instance (`instance::resolve_or_create_instance`: own live binding → party's live instance → /// create; solo allowed; 5-player cap enforced here at trigger time) and teleports into it. Every diff --git a/module/src/reputation.rs b/module/src/reputation.rs index 297c99e9b..30d49d3bc 100644 --- a/module/src/reputation.rs +++ b/module/src/reputation.rs @@ -33,7 +33,7 @@ pub struct PlayerReputation { // 0 filler in practice; the 4 pre-migration rows are backfilled at deploy. #[default(0)] pub reputation_index: i32, - // END-APPENDED (195 slice B): the player checked "At War" for this faction in the rep pane + // END-APPENDED: the player checked "At War" for this faction in the rep pane // (CMSG_SET_FACTION_ATWAR). Persisted so the checkbox survives relog (folded into the login // SMSG_INITIALIZE_FACTIONS flag byte); the gateway's interaction-reaction gate treats an // at-war faction's NPCs as hostile. Defaulted bool → additive auto-migrate. diff --git a/module/src/seed.rs b/module/src/seed.rs index f2bc9bd20..97cf6fb9a 100644 --- a/module/src/seed.rs +++ b/module/src/seed.rs @@ -603,7 +603,7 @@ fn seed_map0_demo_content(ctx: &ReducerContext) { state: 0, created_at: ctx.timestamp, respawn_at_micros: 0, // a freshly-seeded node is ready (no pending respawn) - instance_id: 0, // seeded demo GOs live in the open world (190 slice 2), + instance_id: 0, // seeded demo GOs live in the open world grid_x: lyracore_shared::spatial::grid_cell(hw::X + 5.0, hw::Y).0, grid_y: lyracore_shared::spatial::grid_cell(hw::X + 5.0, hw::Y).1, cell: lyracore_shared::spatial::cell_id_at(hw::X + 5.0, hw::Y), @@ -638,7 +638,7 @@ fn seed_map0_demo_content(ctx: &ReducerContext) { state: 0, created_at: ctx.timestamp, respawn_at_micros: 0, // a freshly-seeded node is ready (no pending respawn) - instance_id: 0, // seeded demo GOs live in the open world (190 slice 2), + instance_id: 0, // seeded demo GOs live in the open world grid_x: lyracore_shared::spatial::grid_cell(hw::X + 8.0, hw::Y).0, grid_y: lyracore_shared::spatial::grid_cell(hw::X + 8.0, hw::Y).1, cell: lyracore_shared::spatial::cell_id_at(hw::X + 8.0, hw::Y), @@ -679,7 +679,7 @@ fn seed_map0_demo_content(ctx: &ReducerContext) { state: 0, created_at: ctx.timestamp, respawn_at_micros: 0, // a freshly-seeded node is ready (no pending respawn) - instance_id: 0, // seeded demo GOs live in the open world (190 slice 2), + instance_id: 0, // seeded demo GOs live in the open world grid_x: lyracore_shared::spatial::grid_cell(hw::X + 6.0, hw::Y).0, grid_y: lyracore_shared::spatial::grid_cell(hw::X + 6.0, hw::Y).1, cell: lyracore_shared::spatial::cell_id_at(hw::X + 6.0, hw::Y), @@ -714,7 +714,7 @@ fn seed_map0_demo_content(ctx: &ReducerContext) { state: 0, created_at: ctx.timestamp, respawn_at_micros: 0, // a freshly-seeded node is ready (no pending respawn) - instance_id: 0, // seeded demo GOs live in the open world (190 slice 2), + instance_id: 0, // seeded demo GOs live in the open world grid_x: lyracore_shared::spatial::grid_cell(hw::X + 7.0, hw::Y).0, grid_y: lyracore_shared::spatial::grid_cell(hw::X + 7.0, hw::Y).1, cell: lyracore_shared::spatial::cell_id_at(hw::X + 7.0, hw::Y), @@ -735,7 +735,7 @@ fn seed_map0_demo_content(ctx: &ReducerContext) { // REAL entries 1731/1732 (NOT the synthetic 50102 Copper Vein above): both type 25 GATHER, line 186 // MINING. INIT-ONLY (the live pool/member rows are made here + arm); a re-import (`DELETE FROM // game_gameobject_pool WHERE pool_id > 0`) wipes this pool, so on the live/imported DB it is re-seeded - // post-import via `debug_setup_gather_pool 2 1 true...`. pool_id 2 + // post-import via `debug_setup_gather_pool 2 1 true ...`. pool_id 2 // is distinct from the debug pool (1) and the importer's roaming base (1000). Ensure the two tier // templates exist first (idempotent — the bare seed lacks them; the ETL also loads them). for (e, name, item, req) in [ @@ -1236,7 +1236,7 @@ fn seed_spell_registry(ctx: &ReducerContext) { // Test Fear (50022) — the FEAR crowd-control: ONE A_CONTROL (0xB0) effect whose p0 names the MECHANIC // M_FEAR (3, p0_kind 3 P_MECHANIC), targeting an ENEMY. A feared unit cannot ACT (no swing/cast — the - // TION gates fold fear in) and is force-walked AWAY from the caster by the fear-flee pass each tick + // ACTION gates fold fear in) and is force-walked AWAY from the caster by the fear-flee pass each tick // ("flees in terror"); it stays engaged so it resumes attacking when the aura ends. SHORT 8s duration // (≈2 ticks, like Warlock Fear) — bounded so the test subject doesn't run off the map. aura_interrupt // stays 0: base fear does NOT break on damage (unlike polymorph). is_negative true. @@ -1309,8 +1309,8 @@ fn seed_spell_registry(ctx: &ReducerContext) { // `debug_seed_talents` (init does NOT re-run on an auto-migrate publish, so the live DB re-seeds via that). crate::talent::seed_talents(ctx); - // Stacking-group starter set, hand-authored until 102's cmangos `spell_group` SQL - // dump lands wholesale. Idempotent + shared with `debug_repair_after_publish`, which is how an + // Stacking-group starter set, hand-authored until a cmangos `spell_group` SQL + // dump is imported wholesale. Idempotent + shared with `debug_repair_after_publish`, which is how an // already-migrated development database picks up reconciled rows (init does NOT re-run). seed_spell_groups(ctx); } @@ -1563,8 +1563,8 @@ fn seed_scheduler_arming(ctx: &ReducerContext) { // `debug_rearm_creature_tick` reducer (init does NOT re-run on a plain publish). // this seeded row is the GLOBAL/CATCH-ALL ticker (`GLOBAL_TICK_INSTANCE`), it // covers instance 0 AND every instance without a dedicated row of its own (load-bearing; never - // delete it). Dedicated per-instance rows are inserted by 190 slice 2's create_instance (or, until - // then, `debug_arm_instance_tick`). + // delete it). Dedicated per-instance rows are inserted by `create_instance` (or by + // `debug_arm_instance_tick`). ctx.db .game_creature_move_schedule() .insert(CreatureMoveSchedule { @@ -1609,7 +1609,7 @@ fn seed_scheduler_arming(ctx: &ReducerContext) { )), }); - // Ground-AoE damage tick every 500ms (118): drives game_ground_area (Consecration/…). 500ms so a + // Ground-AoE damage tick every 500ms: drives game_ground_area (Consecration/…). 500ms so a // 1s/2s area period fires within ~½ tick of due. Areas gate on their own next_tick_micros. ctx.db .game_ground_area_schedule() @@ -1634,10 +1634,10 @@ fn seed_scheduler_arming(ctx: &ReducerContext) { )), }); - // Gateway lease reaper (stage 4a): despawns the players of a gateway that stopped + // Gateway lease reaper: despawns the players of a gateway that stopped // heartbeating (the shared-connection crash case). Inert while `game_gateway_session` is - // empty — nothing binds sessions to leases until stage 4d — but armed from day one so the - // ghost bound exists the moment the first leased session appears. Same three-net story as + // empty, but armed from day one so the ghost bound exists the moment the first leased session + // appears. Same three-net story as // the motion tick above: `debug_repair_after_publish` ensures it on a live DB. ctx.db .game_gateway_lease_reaper_schedule() @@ -1689,7 +1689,7 @@ pub(crate) fn seed_createinfo_spells(ctx: &ReducerContext) { } } -/// The stacking-group starter set, hand-authored ahead of 102's cmangos `spell_group`/ +/// The stacking-group starter set, hand-authored ahead of a cmangos `spell_group`/ /// `spell_group_stack_rules` SQL dump, which will fill `game_spell_group`/`game_spell_group_rule` /// wholesale and supersede this. Idempotent (only-if-empty, mirroring `seed_createinfo_spells`); shared by /// `init` and `debug_repair_after_publish` (init does NOT re-run on an auto-migrate publish). It diff --git a/module/src/seed/fixtures.rs b/module/src/seed/fixtures.rs index a04b9084e..6d05e5f19 100644 --- a/module/src/seed/fixtures.rs +++ b/module/src/seed/fixtures.rs @@ -572,7 +572,7 @@ pub(crate) fn base_item(entry: u32, name: &str) -> ItemTemplate { /// already-migrated dev DB (where `init` did not re-run). /// /// Weakened Soul (REAL vanilla id 6788) is the hardcoded Power Word: Shield lockout debuff. Its real -/// Spell.dbc shape (CONFIRMED via a DBC dry-run for, this is NOT the effectless marker +/// Spell.dbc shape (CONFIRMED via a DBC dry-run; it is NOT the effectless marker /// earlier believed) is a single `A_IMMUNITY` (0xB1) aura with MiscValue 19 (MECHANIC_SHIELD): vanilla's /// actual "immune to the shield mechanic" (i.e. can't be re-shielded) effect. 15s duration, holy school /// (school_mask 2), dispel_type 0 — mirroring the importer's DBC output so a seed-only dev DB matches a @@ -652,7 +652,7 @@ pub(crate) fn seed_pw_shield_fixture(ctx: &ReducerContext) { ); } -/// Hand-seed the Soul Shard item template (real vanilla item 6265). The.import ETL +/// Hand-seed the Soul Shard item template (real vanilla item 6265). The import ETL /// doesn't reliably carry it, so — mirroring `seed_pw_shield_fixture`'s precedent for a mechanic whose /// live-DBC row isn't available in every dev environment — it's authored here. A plain, non-equippable, /// non-sellable trade good (vanilla: Soul Shard cannot be sold to a vendor; `sell_price: 0` encodes @@ -1313,7 +1313,7 @@ pub(crate) fn seed_fixture_catalogue(ctx: &ReducerContext) { } } -/// Scenario-runner mock-seed: everything the four wire scenarios need on a +/// Scenario-runner seed: everything the four wire scenarios need on a /// no-import sandbox, insert-if-absent like every other fixture here. Same precedent as /// `seed_pw_shield_fixture` — call via `debug_seed_scenario_fixtures` post-publish. /// @@ -1734,7 +1734,7 @@ pub(crate) fn seed_scenario_fixtures(ctx: &ReducerContext) { /// /// THE DISCIPLINE: delete-then-insert is idempotent BY CONSTRUCTION — calling it twice with /// the same row is a no-op, and calling it with a changed shape self-corrects the row in place. So -/// every call site below calls this UNCONDITIONALLY, with no `if find(id).is_none() {... }` guard +/// every call site below calls this UNCONDITIONALLY, with no `if find(id).is_none() { ... }` guard /// around it. A guard doesn't just add noise: it makes the delete dead code (the branch that would /// run it never fires when the row already exists), which silently turns "re-seed self-corrects" /// back into "re-seed only fills gaps" — exactly the bug class this fn exists to prevent. If a diff --git a/module/src/spell/cast/resolve.rs b/module/src/spell/cast/resolve.rs index 0068a505f..82ad5a116 100644 --- a/module/src/spell/cast/resolve.rs +++ b/module/src/spell/cast/resolve.rs @@ -52,7 +52,7 @@ pub(crate) fn resolve_cast_at( // its duplicate. Channel ticks / triggers / creature / debug / item-use casts pass false and the // relay DELIVERS the caster's visual (they never had a synchronous send). client_initiated: bool, - // The clicked GROUND point (118, for a ground-targeted cast (CMSG_CAST_SPELL's + // The clicked GROUND point for a ground-targeted cast (CMSG_CAST_SPELL's // DEST_LOCATION). `Some` → AREA effects splash from it and a ground patch anchors there; `None` for // every non-ground cast (self/unit-target/creature/trigger). Threaded verbatim to select_targets + // create_ground_area. @@ -88,7 +88,7 @@ pub(crate) fn resolve_cast_at_typed( // its duplicate. Channel ticks / triggers / creature / debug / item-use casts pass false and the // relay DELIVERS the caster's visual (they never had a synchronous send). client_initiated: bool, - // The clicked GROUND point (118, for a ground-targeted cast (CMSG_CAST_SPELL's + // The clicked GROUND point for a ground-targeted cast (CMSG_CAST_SPELL's // DEST_LOCATION). `Some` → AREA effects splash from it and a ground patch anchors there; `None` for // every non-ground cast (self/unit-target/creature/trigger). Threaded verbatim to select_targets + // create_ground_area. @@ -1207,7 +1207,7 @@ fn check_cast_gate_suffix( e.effect_index ))); } - // A ground-AoE (118) with no radius is unauthored data — reject LOUD rather than spawn a 0-radius + // A ground-AoE with no radius is unauthored data — reject LOUD rather than spawn a 0-radius // area that silently damages nobody (same fail-loud stance as Blink's distance). if let Some(e) = effects .iter() @@ -1375,7 +1375,7 @@ pub(crate) fn begin_cast( target_guid: u64, client_initiated: bool, - // The clicked GROUND point (118 for a ground-targeted cast. An INSTANT/channel ground cast + // The clicked GROUND point for a ground-targeted cast. An INSTANT/channel ground cast // passes it straight to resolve_cast_at; a TIMED one stashes it on the PendingCast row so the // completion (`fire_pending_cast`) can anchor the patch at the click. `None` for every normal cast. dest: Option<(f32, f32, f32)>, diff --git a/module/src/spell/cast/targeting.rs b/module/src/spell/cast/targeting.rs index eac754010..bb11962ab 100644 --- a/module/src/spell/cast/targeting.rs +++ b/module/src/spell/cast/targeting.rs @@ -165,7 +165,7 @@ pub(crate) fn select_targets( caster_guid: u64, explicit: u64, e: &SpellEffect, - // The clicked GROUND point (118. When `Some`, an AREA effect splashes from the dest + // The clicked GROUND point. When `Some`, an AREA effect splashes from the dest // (Flamestrike/Blizzard's initial impact centered on the click), NOT the caster/explicit unit. dest: Option<(f32, f32, f32)>, ) -> Vec { @@ -294,13 +294,13 @@ fn pick_aura_slot( range.find(|s| !existing.iter().any(|(es, _)| es == s)) } -/// Delete every aura on `target_guid` whose `spell_id` is in `spell_ids` (381) — the shared +/// Delete every aura on `target_guid` whose `spell_id` is in `spell_ids` — the shared /// collect-then-delete DISPLACEMENT sweep `aura_apply`'s two single-active-buff-class rules both reduce /// to: a fresh SEAL displacing the other active seal (every sibling effect sharing its spell_id, not /// just the A_SEAL-tagged one), and a same-NAME higher/lower rank displacing the other. Both call sites /// compute `spell_ids` with a DIFFERENT predicate (A_SEAL-sibling scan vs. same-name spell resolution) /// but converge on this identical shape (mirrored by `stacking.rs`'s group-eviction sweep, a third, -/// independent instance of the same pattern), a future `spell_chain` import, the real +/// independent instance of the same pattern). A future `spell_chain` import (the real /// rank-chain data) swaps the RANK predicate that feeds this fn, never a second displacement mechanism. /// A no-op for an empty set (nothing to displace) — every existing call site is baseline-safe by /// construction, since an empty-set filter matches nothing either way. @@ -741,7 +741,7 @@ pub(crate) fn apply_effect( cast_target_guid: u64, level: u8, points: i32, - // The clicked GROUND point (118, only the E_PERSISTENT_AREA arm reads it (anchors the patch + // The clicked GROUND point. Only the E_PERSISTENT_AREA arm reads it (anchors the patch // at the dest); every other effect ignores it. `None` for all non-ground casts. dest: Option<(f32, f32, f32)>, // This effect is running inside a **Triggered Cast** (a fired Proc). Every hit it deals is marked @@ -1280,7 +1280,7 @@ fn apply_reposition_effect( EffectHit::none() } E_PERSISTENT_AREA => { - // GROUND-AoE (118): spawn a fixed-position damage area at the resolved target's position + // GROUND-AoE: spawn a fixed-position damage area at the resolved target's position // (T_SELF/target-0 → the caster; a future clicked-ground variant anchors at the dest coords). // `tick_ground_areas` re-scans + damages from here on; this cast effect just STAMPS the row. // `points` is the per-tick magnitude. resolve_cast_at already rejected a 0-radius area. @@ -1695,7 +1695,7 @@ pub(crate) fn blink_forward(ctx: &ReducerContext, caster_guid: u64, dist_yd: f32 } } -/// GROUND-AoE (118): stamp a `game_ground_area` row at `anchor_guid`'s position (T_SELF/target-0 → the +/// GROUND-AoE: stamp a `game_ground_area` row at `anchor_guid`'s position (T_SELF/target-0 → the /// caster; the clicked-ground dest once plumbed). The area lives for the spell's `duration_ms` and its /// own `tick_ground_areas` re-scans + damages. `amount` is the per-tick magnitude, `e.radius_yd` the /// zone radius (resolve_cast_at already rejected 0). One area per cast (Consecration is T_SELF → the @@ -1707,7 +1707,7 @@ fn create_ground_area( caster_guid: u64, anchor_guid: u64, amount: i32, - // The clicked GROUND point (118. `Some` → the area anchors there (map/instance still come from + // The clicked GROUND point. `Some` → the area anchors there (map/instance still come from // the caster); `None` → it anchors on `anchor_guid`'s entity position (Consecration's T_SELF paladin). dest: Option<(f32, f32, f32)>, ) { @@ -1765,7 +1765,7 @@ fn create_ground_area( next_tick_micros: now.to_micros_since_unix_epoch() + (period_ms as i64) * 1000, expires_at, }); - // The client-visible swirl (118): one DYNAMICOBJECT per area — the 5875 client renders the + // The client-visible swirl: one DYNAMICOBJECT per area — the 5875 client renders the // ground effect from its DYNAMICOBJECT_SPELLID SpellVisual (a CREATE relayed by the gateway). // Guid = HIGHGUID_DYNAMICOBJECT | the area's fresh id; the tick's reap deletes it in lockstep. ctx.db diff --git a/module/src/spell/control.rs b/module/src/spell/control.rs index 9f92780a6..2dedc4406 100644 --- a/module/src/spell/control.rs +++ b/module/src/spell/control.rs @@ -201,7 +201,7 @@ pub fn is_rooted(ctx: &ReducerContext, unit_guid: u64) -> bool { } /// Is `unit_guid` FEARED — carrying an active `A_CONTROL(M_FEAR)` aura? A feared unit cannot ACT (the -/// TION gates fold it into `is_action_blocked`) and does not steer its OWN movement; instead the +/// ACTION gates fold it into `is_action_blocked`) and does not steer its OWN movement; instead the /// creature fear-flee pass force-walks it AWAY from the fear source each tick ("flees in terror"). It is /// the one mechanic that MOVES the unit: stun/poly freeze (no act, no move), root pins (act, no move), /// fear routs (no act, forced flee). Pure read over `game_aura` (no entity write); `false` for any unit diff --git a/module/src/spell/scheduler.rs b/module/src/spell/scheduler.rs index 3be2ba845..9606a657a 100644 --- a/module/src/spell/scheduler.rs +++ b/module/src/spell/scheduler.rs @@ -41,7 +41,7 @@ pub fn fire_pending_cast(ctx: &ReducerContext, sched: PendingCast) { // NOT client-initiated (088): the completion GO must reach the caster via the relay — the // synchronous send only covered the instant CMSG path, never a timed completion. false, - // The clicked ground point carried across the cast bar (118: a timed ground-AoE + // The clicked ground point carried across the cast bar: a timed ground-AoE // (Flamestrike) anchors its patch here at COMPLETION. `has_dest` false → None (every normal cast). sched .has_dest @@ -738,7 +738,7 @@ fn flush_pool( } } -/// GROUND-AoE damage tick (118): the twin of `tick_auras`' periodic pass, but keyed on a fixed WORLD +/// GROUND-AoE damage tick: the twin of `tick_auras`' periodic pass, but keyed on a fixed WORLD /// POSITION instead of a unit. Each due `game_ground_area` re-scans its radius (live — a mob that walks /// in mid-duration is hit; one that leaves simply isn't in the next scan, no per-unit aura bookkeeping) /// and applies one tick of `amount` `school` damage to every HOSTILE inside via the shared @@ -828,7 +828,7 @@ pub fn tick_ground_areas(ctx: &ReducerContext, _schedule: GroundAreaSchedule) { resisted, crate::combat::Hit::triggered(), ); - // Per-tick feedback (118, user: area damage "does not show on damage numbers or in the + // Per-tick feedback (user report: area damage "does not show on damage numbers or in the // combat log"): a log-only cast-event row (`is_proc_log`, same shape as the seal's holy // line) — the gateway relays ONLY the SMSG_SPELLNONMELEEDAMAGELOG named after the area's // spell ("Consecration hits X for N Holy"), never START/GO. Emitted for a landed OR diff --git a/module/src/spell/tables.rs b/module/src/spell/tables.rs index b8912ac5a..bc2368566 100644 --- a/module/src/spell/tables.rs +++ b/module/src/spell/tables.rs @@ -113,7 +113,7 @@ pub struct SpellEffect { } /// One REAGENT a spell consumes: the real multi-reagent recipe model that replaced -/// the hardcoded `cast::RECIPES` map). Filled by the importer from `Spell.dbc` `Reagent[1-8]` / +/// the hardcoded `cast::RECIPES` map. Filled by the importer from `Spell.dbc` `Reagent[1-8]` / /// `ReagentCount[1-8]` during the wholesale spell import — every real recipe (and any reagent- /// consuming spell) carries its true mats here, keyed by the REAL vanilla spell id, so the craft /// gate resolves by data instead of a hardcoded id list. Module-private (the craft reducer reads @@ -390,7 +390,7 @@ pub struct SpellCastEvent { pub delay_ms: u32, // EFFECTIVE health restored to the primary target by this cast (overheal excluded; summed // across E_HEAL effects). >0 on the cast-GO row → the gateway relays SMSG_SPELLHEALLOG (the - // green floating number + combat-log line. Binding hand-synced (see the + // green floating number + combat-log line). Binding hand-synced (see the // delay_ms note above). END-appended + #[default(0u32)] → additive auto-migration. #[default(0u32)] pub healed: u32, @@ -529,7 +529,7 @@ pub struct SpellCooldown { /// can be off-GCD yet still on its own cooldown). A `cooldown_ms == 0` spell writes NO row here, so every /// current seed spell is unaffected (baseline-safe). Logical key is `(caster_guid, spell_id)`; mirrors the /// `game_aura` idiom — an `#[auto_inc]` PK plus a `by_caster` btree index, the specific spell located with -/// a `.find(|r| r.spell_id ==..)` on the caster's rows (no lossy u64+u32 PK pack). A NEW table → the GCD +/// a `.find(|r| r.spell_id == ..)` on the caster's rows (no lossy u64+u32 PK pack). A NEW table → the GCD /// binding is untouched; this table's binding must be generated for the gateway to relay SMSG_SPELL_COOLDOWN. #[table(accessor = game_spell_cd, public, index(accessor = by_caster, btree(columns = [caster_guid])))] pub struct SpellCd { @@ -545,7 +545,7 @@ pub struct SpellCd { /// victim's whole SCHOOL of magic is locked for ~5s: a cast whose `game_spell.school_mask` matches a LIVE /// lockout row for that caster is rejected by `resolve_cast_at` (until `until > now`). Logical key is /// `(caster_guid, school)`; mirrors the `game_spell_cd` idiom — an `#[auto_inc]` PK plus a `by_caster` -/// btree, the specific school located with `.find(|r| r.school ==..)` on the caster's rows. A NEW public +/// btree, the specific school located with `.find(|r| r.school == ..)` on the caster's rows. A NEW public /// table → no existing binding changes, and a plain `publish` auto-migrates it (additive, no rows). Public /// so the gateway can later relay an SMSG_SPELL_COOLDOWN-style lock (the server-authoritative gate works /// without it). Rows are never GC'd (like `game_spell_cd`): the `until > now` predicate makes expired rows @@ -570,7 +570,7 @@ pub struct AuraSchedule { pub scheduled_at: ScheduleAt, } -/// A GROUND-AoE persistent damage area (118): a fixed-position zone spawned by an `E_PERSISTENT_AREA` +/// A GROUND-AoE persistent damage area: a fixed-position zone spawned by an `E_PERSISTENT_AREA` /// cast (Consecration / Blizzard / Rain of Fire / Flamestrike's patch). `tick_ground_areas` re-scans /// `radius_yd` around `(x,y,z)` every `period_ms` and applies `amount` `school_mask` damage to every /// hostile inside (via the shared apply_resistance→apply_target_damage path), reaping the row at @@ -597,7 +597,7 @@ pub struct GroundArea { pub expires_at: Timestamp, } -/// The client-visible half of a ground area (118): a vanilla DYNAMICOBJECT — the 5875 client draws +/// The client-visible half of a ground area: a vanilla DYNAMICOBJECT — the 5875 client draws /// Consecration's ground swirl from a DynamicObject CREATE (DYNAMICOBJECT_SPELLID → SpellVisual), /// NEVER from the cast packets alone (live find: no swirl rendered). One row per live /// `game_ground_area`, guid = (0xF100 << 48) | area id (HIGHGUID_DYNAMICOBJECT, disjoint from the @@ -617,7 +617,7 @@ pub struct DynamicObject { pub radius_yd: f32, } -/// Drives the ground-AoE damage tick (118). [server] +/// Drives the ground-AoE damage tick. [server] #[table(accessor = game_ground_area_schedule, scheduled(tick_ground_areas))] pub struct GroundAreaSchedule { #[primary_key] @@ -653,7 +653,7 @@ pub struct PendingCast { // auto-migration (the publish-migration rule). #[default(0)] pub pushback_count: u8, - // GROUND-TARGET dest (118: the clicked ground point for a ground-AoE cast (Flamestrike patch, + // GROUND-TARGET dest: the clicked ground point for a ground-AoE cast (Flamestrike patch, // Blizzard, Rain of Fire) so a TIMED ground cast carries its dest from begin_cast to the completion // (`fire_pending_cast` → `resolve_cast_at`). `has_dest` distinguishes "no dest" from a legitimate // (0,0,0) point. Same END-appended `#[default]` additive auto-migration as `pushback_count`; still no diff --git a/module/src/spell/taxonomy.rs b/module/src/spell/taxonomy.rs index 72387751c..8819bf5b4 100644 --- a/module/src/spell/taxonomy.rs +++ b/module/src/spell/taxonomy.rs @@ -68,7 +68,7 @@ pub(crate) const SPELL_ATTR_REQ_DAGGER: u32 = 0x0100; pub(crate) const HUMANOID_TYPE: u8 = 7; /// cmangos `CREATURE_TYPE_BEAST` — the creature_type value a corpse must carry to be SKINNABLE (Skinning -/// profession, slice 2). Imported verbatim by the importer (`ct::CREATURE_TYPE`), so Elwynn wolves/boars +/// profession). Imported verbatim by the importer (`ct::CREATURE_TYPE`), so Elwynn wolves/boars /// arrive as type 1. Shared by `loot::skin_corpse`'s beast gate + the `debug_skin_nearest` nearest-search. pub(crate) const BEAST_TYPE: u8 = 1; @@ -103,7 +103,7 @@ pub(crate) const E_TAME_CREATURE: u8 = 0x20; // completed Hunter tame: convert t pub(crate) const E_FEED_PET: u8 = 0x21; // consume an explicit item target after Hunter-pet care validation pub(crate) const E_ENCHANT_ITEM: u8 = 0x17; // apply a permanent enchant to the cast's ITEM target: p0 = the enchant_id (p0_kind = P_ENCHANT_ID), looked up in the imported enchantment catalogue for the stat bonus. The spell never runs through resolve_cast (it targets an item GUID, not a unit) — the GATEWAY intercepts CMSG_CAST_SPELL, sees this effect kind, resolves the item GUID→bag slot, and calls enchant_item_on_slot(slot, p0). Routing is by KIND + p0, NOT a hardcoded spell-id list, so a new enchant is a data row pub(crate) const E_DISENCHANT: u8 = 0x18; // disenchant the cast's ITEM target into reagents: same gateway-intercept path as E_ENCHANT_ITEM, dispatched to disenchant_item(slot). No params (the module validates disenchantability + yields dust by item) -pub(crate) const E_PERSISTENT_AREA: u8 = 0x1B; // GROUND-AoE (118, Consecration/Blizzard/Rain of Fire/Flamestrike-patch): an INSTANT effect that, on cast, spawns a fixed-position `game_ground_area` row at the anchor (caster pos for a self/target-0 area; the clicked ground dest once dest-coords are plumbed). Its own scheduled `tick_ground_areas` re-scans the radius each `period_ms` and applies `amount` `school` damage to every hostile inside via the shared apply_resistance→apply_target_damage path (threat/kill/absorb/break-CC reused), reaping the row at `expires_at`. DIRECT per-tick damage (no per-unit aura churn) — the clean-slate choice over mangos' DynamicObject+SpellAuraHolder-add/remove, equivalent for pure-damage areas. The importer reclassifies the ground A_PERIODIC_DAMAGE effect to this kind BY NAME. A future non-damage ground field (heal/slow) extends the emitted descriptor +pub(crate) const E_PERSISTENT_AREA: u8 = 0x1B; // GROUND-AoE (Consecration/Blizzard/Rain of Fire/Flamestrike-patch): an INSTANT effect that, on cast, spawns a fixed-position `game_ground_area` row at the anchor (caster pos for a self/target-0 area; the clicked ground dest once dest-coords are plumbed). Its own scheduled `tick_ground_areas` re-scans the radius each `period_ms` and applies `amount` `school` damage to every hostile inside via the shared apply_resistance→apply_target_damage path (threat/kill/absorb/break-CC reused), reaping the row at `expires_at`. DIRECT per-tick damage (no per-unit aura churn) — the clean-slate choice over mangos' DynamicObject+SpellAuraHolder-add/remove, equivalent for pure-damage areas. The importer reclassifies the ground A_PERIODIC_DAMAGE effect to this kind BY NAME. A future non-damage ground field (heal/slow) extends the emitted descriptor pub(crate) const E_FISH: u8 = 0x1C; // Fishing (060): gateway-intercepted like E_ENCHANT_ITEM/E_DISENCHANT — CMSG_CAST_SPELL for a spell carrying this kind routes to the `fish` reducer (instant-resolve alpha catch; the bobber/channel flow is the deferred follow-up). Inert in-module (no resolve arm); exists so the gateway routes by DATA, never a spell-id list. pub(crate) const E_OPEN_LOCK: u8 = 0x1D; // Pick Lock (119): gateway-intercepted like E_FISH (0x1C) — CMSG_CAST_SPELL for a spell carrying this kind routes to the `pick_lock` reducer (unlock a locked GameObject, gated on the caster's Lockpicking 633 skill vs the game_lock required_skill). Inert in-module (NO resolve arm in cast.rs); exists so the gateway routes by DATA, never a spell-id list. 0x1E is reserved for a future E_SUMMON_PORTAL — do NOT reuse. pub(crate) const E_BLINK: u8 = 0x1A; // teleport the caster ~20yd FORWARD along its facing (Mage Blink, 116): a self-cast position change reusing the teleport core (like E_CHARGE), clamped to the furthest nav-LoS-clear point so it doesn't cross geometry. Root/snare removal rides a separate A_IMMUNITY effect. The importer name-rescues the dead SCRIPT teleport effect (raw 29) to this kind diff --git a/module/src/talent.rs b/module/src/talent.rs index d976a3bfb..aed4023c2 100644 --- a/module/src/talent.rs +++ b/module/src/talent.rs @@ -38,7 +38,7 @@ pub struct Talent { /// and this is the granted active. END-appended `#[default(0)]` → auto-migrates. [static] #[default(0)] pub grant_spell_id: u32, - // --- Talent.dbc import columns, END-appended, #[default(0)] → additive auto-migration). + // --- Talent.dbc import columns, END-appended, #[default(0)] → additive auto-migration. // Every demo-seeded talent (ids 1-8) leaves these at 0 — byte-identical baseline behavior. /// The real `game_talent_tab.tab_id` this talent belongs to (Talent.dbc `Talent.tab`) — 0 for a /// demo-seeded talent (no real tab). Unlike `tree_id` (which repeats 0/1/2 across EVERY class's tabs), @@ -738,7 +738,7 @@ pub(crate) fn do_reset_talents( // Seed (shared by init + debug_seed_talents — init does NOT re-run on an auto-migrate publish) // =========================================================================================== -/// Seed the first-slice Warrior talents: the `game_talent` metadata + the per-talent passive spell +/// Seed the first Warrior talents: the `game_talent` metadata + the per-talent passive spell /// (`game_spell` + a single `game_spell_effect`). IDEMPOTENT — inserts only rows that are absent — so it is /// safe to call from `seed::init` (fresh install) AND from `debug_seed_talents` on an already-migrated dev /// DB (where `init` did not re-run). Spell ids live in a reserved 51xxx range, ABOVE the vanilla spell ids diff --git a/module/src/terrain.rs b/module/src/terrain.rs index a60f57a6d..a952bead0 100644 --- a/module/src/terrain.rs +++ b/module/src/terrain.rs @@ -35,8 +35,7 @@ pub struct TerrainChunk { } /// Ground height at (x, y), or `None` where no terrain is imported. ONE indexed PK find plus -/// arithmetic, this sits in the 0.5s movement tick, so it must never scan / -/// de-risking spike). The cell math + bilinear interpolation live in +/// arithmetic. This sits in the 0.5s movement tick, so it must never scan. The cell math + bilinear interpolation live in /// `lyracore_shared::terrain` (unit-tested there; the importer's self-check shares the SAME code, /// so import-time verification covers this exact function). pub fn ground_z(ctx: &ReducerContext, map_id: u32, x: f32, y: f32) -> Option { @@ -149,12 +148,12 @@ pub fn debug_check_submerged(ctx: &ReducerContext, guid: u64) -> Result<(), Stri /// Chase the position's MCNK `area_id` (`area_id_at` above) ONE hop up `game_area.parent_area_id` to /// its enclosing zone — e.g. a Goldshire subzone area resolves to zone 12 (Elwynn). NOT a full /// recursive area-hierarchy walk (a subzone-of-a-subzone would need more than one hop; deferred to -///, which needs full area resolution for exploration XP anyway). Returns `None` when +/// the exploration XP work, which needs full area resolution anyway). Returns `None` when /// `game_area` is empty (unimported) or the position's terrain cell has no recorded/imported area, so /// callers skip zone-scoping entirely rather than guessing wrong — a wrong guess would silently narrow /// a candidate set (graveyards, fishing loot) to the WRONG zone. /// -/// The single canonical zone resolver (idiom): `world::graveyard`'s release pick +/// The single canonical zone resolver: `world::graveyard`'s release pick /// and `loot::apply_fish`'s catch roll both call this instead of each keeping its own one-hop walk. pub fn zone_id_at(ctx: &ReducerContext, map_id: u32, x: f32, y: f32) -> Option { area_at(ctx, map_id, x, y).map(|area| zone_of(&area)) diff --git a/module/src/trainer.rs b/module/src/trainer.rs index 8741c7c1d..46cddd1e1 100644 --- a/module/src/trainer.rs +++ b/module/src/trainer.rs @@ -50,7 +50,7 @@ pub struct TrainerSpell { pub spell_id: u32, // the spell taught pub cost: u32, // copper charged on purchase pub required_level: u8, // minimum character level to learn it - // END-APPENDED (professions slice 3), defaulted 0 → `publish` auto-migrates + every existing + // END-APPENDED, defaulted 0 → `publish` auto-migrates + every existing // class-spell row reads 0 (the unchanged spell path). 0 = a normal spell offering; >0 = this // offering TEACHES that skill_line as a profession (Cooking=185 / Skinning=393) — the buy branch // grants the skill via `crate::skill::learn_profession` instead of casting/learning a spell. The @@ -68,7 +68,7 @@ pub struct TrainerSpell { pub learn_skill_cap: u32, } -/// What a successful trainer purchase GRANTS (professions slice 3) — `crate::skill::learn_profession` for +/// What a successful trainer purchase GRANTS — `crate::skill::learn_profession` for /// a flagged profession/weapon offering, `crate::spell::learn_spell_with_dependents` for a plain class /// spell. Each `apply_trainer_buy` arm constructs its OWN variant directly at the point it already knows /// which one applies — there is no longer a separate `grant_for` routing function: the old @@ -91,7 +91,7 @@ pub(crate) enum BuyGrant { } /// Whether a profession offering at `cap` is ALREADY covered by the character's stored `max_rank` for -/// that skill line (professions slice 3's rank/cap scaling) — the pure comparison the buy path's +/// that skill line (its rank/cap scaling) — the pure comparison the buy path's /// already-known gate keys on for a profession offering (a class-spell offering uses `knows_spell` /// instead). Extracted from `apply_trainer_buy` (pure code-motion). `None` (no `game_player_skill` row /// at all — never learned) is never capped. `stored_max_rank >= cap` is the boundary: exactly meeting @@ -500,7 +500,7 @@ pub(crate) fn apply_trainer_buy( ) })?; - // PROFESSION-LEARN BRANCH (professions slice 3): a flagged offering teaches a SKILL, not a spell — + // PROFESSION-LEARN BRANCH: a flagged offering teaches a SKILL, not a spell — // it never resolves a wrapper/rank, never casts, never touches `game_player_spell`. `known` mirrors // `learn_profession`'s presence check (a `game_player_skill` row for that line → a re-buy is the // idempotent already-known no-op), and on Ok it grants the skill at 1/75. The `learn_skill_line == 0` @@ -767,7 +767,7 @@ mod tests { ); } - /// THE IDEMPOTENT RE-LEARN (professions slice 3): the profession buy keys its already-known gate on the + /// THE IDEMPOTENT RE-LEARN: the profession buy keys its already-known gate on the /// PRESENCE of a `game_player_skill` row for the line (the `known` flag the buy path derives), and feeds /// it through the SAME `trainer_buy_check`. So a re-buy of an already-learned profession is rejected with /// `TrainerRefusal::AlreadyKnown` (no re-charge, no duplicate/reset row) — identical to a re-bought known spell. diff --git a/module/src/transfer/mod.rs b/module/src/transfer/mod.rs index 321c0c78e..f1a6601e8 100644 --- a/module/src/transfer/mod.rs +++ b/module/src/transfer/mod.rs @@ -1515,7 +1515,7 @@ pub(crate) fn apply_import_blob( sink.detach_for_transfer(guid); sink.cascade_delete_character(guid, ListingHolds::Keep); sink.insert_character(c); - // ratchet this database's guid allocator past `guid` NOW, in the same + // Ratchet this database's guid allocator past `guid` NOW, in the same // transaction as the materialisation — so a `create_character` racing this import (or run any // time after) can never hand the same guid to a brand-new local character. // @@ -1894,7 +1894,7 @@ pub(crate) fn apply_finish(sink: &mut S, transfer_id: u64) { sink.detach_for_transfer(out.character_guid); sink.cascade_delete_character(out.character_guid, listing_holds_after(&out.blob)); } - // the character→shard index entry is written HERE, inside the same transaction that + // The character→shard index entry is written HERE, inside the same transaction that // releases the escrow, from the out-row's own destination fields — so "the escrow settled" and // "the directory says where it settled" can never disagree on this database. // diff --git a/module/src/tripwires.rs b/module/src/tripwires.rs index 17e6c244b..73871f110 100644 --- a/module/src/tripwires.rs +++ b/module/src/tripwires.rs @@ -203,12 +203,8 @@ pub(crate) mod character_owned_tripwire { ]; /// Every `.rs` file that compiles into this crate: core `src/` plus each drop-in - /// `packages/*/src/` (build.rs compiles those in, so every source-scanning tripwire must see - /// them too). Shared with `partition_discipline_tripwire` below and `gc_reap_tripwire`. - /// - /// `pub(crate)`, not `pub(super)`: `creatures::tick`'s - /// `nothing_writes_the_unsubscribed_move_event_table` walks the whole compiled tree the - /// same way, from outside this module entirely. + /// `packages/*/src/` (build.rs compiles those in, so every source-scanning Architecture Test + /// must see them too). Shared with the other Architecture Tests in this file. pub(crate) fn scanned_files() -> Vec { let manifest_dir = env!("CARGO_MANIFEST_DIR"); let mut files = Vec::new(); @@ -870,9 +866,7 @@ mod partition_discipline_tripwire { /// `(repo-relative path, allowed raw-scan count, why)`. One line of justification each. const WHITELIST: &[(&str, usize, &str)] = &[ - // Diagnostics and harness code — never on a gameplay path. A split of the former single - // `debug.rs` (budget 9, down from 12) into a directory; the 9 raw scans - // landed in two of the seven files — same total, just split along the new file boundary. + // Diagnostics and harness code, never on a gameplay path. ("module/src/debug/mod.rs", 9, "Debug fixture and operator migration reducers inspect the whole Shard, including the packed-cell backfill."), ("module/src/debug/instance.rs", 3, "Debug fixture setup and floor validation inspect template populations and guid allocation."), // Importers — realm-wide by definition: they wipe and rebuild every partition at once. @@ -887,14 +881,12 @@ mod partition_discipline_tripwire { // Unindexed lookups whose target is co-located with the caller anyway. ("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"), - // Split tick.rs into tick/{mod,movement,lifecycle,sense}.rs; this budget-5 entry - // splits with it, same total (3 + 2 = 5), same reasoning as the pre-split note below. + // Creature tick scans: `tick/mod.rs` and `cycle/ctx.rs`. ("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)"), - // Both nearest-trainer scans now route through `helpers::nearest_entity` (an - // indexed `by_map` scan, partition-scoped) — this file's raw-scan count dropped to 0, so - // it no longer needs a whitelist entry. + // Nearest-trainer scans go through `helpers::nearest_entity` (an indexed `by_map` scan, + // partition-scoped), so the trainer files have no raw scans and no entry. ]; /// The text at `byte_idx` opens a whole-table read: `.iter()`, or `.count()` (the same read @@ -1004,11 +996,11 @@ mod partition_discipline_tripwire { // EXTENSION: no module game logic may read a SHARD ID. // ========================================================================================== - /// The only files allowed to touch a shard column: the one that DEFINES the assignment table - /// and the operator reducer that writes it (`region.rs`), and the one that DEFINES the - /// per-shard load-sample table and ITS operator reducer (`load.rs`) — `game_shard_load - /// equality, exactly like `RegionAssignment.shard`. Both write or compare; neither branches on - /// the value to decide anything gameplay-visible. + /// The only files allowed to touch a shard column: `region.rs` defines the assignment table and + /// the operator reducer that writes it, and `load.rs` defines the per-shard load-sample table + /// and its operator reducer. `game_shard_load.shard` is a database-name LABEL an ops sample is + /// filed under, ring-evicted and compared for equality, exactly like `RegionAssignment.shard`. + /// Both write or compare. Neither branches on the value to decide anything gameplay-visible. const SHARD_ID_OWNERS: &[&str] = &["module/src/region.rs", "module/src/load.rs"]; /// The ways module code could reach a shard id: the assignment table's accessor, the `.shard` @@ -1061,23 +1053,18 @@ mod partition_discipline_tripwire { assert_eq!(shard_id_reads(src), vec![(2, ".shard")]); } - /// of **region definitions are data; shard ids are the gateway's business.** The - /// module stores `region_assignment { map_id, region_id, shard, epoch }` (it has to — realm-core - /// is this same wasm under another database name), but the instant a reducer *branches* on - /// `shard` the module stops being relocatable: the same code on two databases would take two - /// different paths, and a region migration would change gameplay instead of changing routing. - /// Regions and cells are fair game anywhere; the database name is not. - /// The module's own `src` + `packages/*` (what the sibling tripwires scan) **plus - /// `crates/lyracore-shared/src`**, which is compiled INTO this module and therefore just as capable - /// of naming a database. + /// Region definitions are data. Shard ids are the Gateway's business. The Module stores + /// `region_assignment { map_id, region_id, shard, epoch }` (it has to, because realm-core is + /// this same wasm under another database name). The instant a reducer *branches* on `shard`, + /// the Module stops being relocatable: the same code on two databases would take two different + /// paths, and a region migration would change gameplay instead of changing routing. Regions and + /// cells are fair game anywhere. The database name is not. /// - /// Moved `DUNGEON_MAPS` out of `module/src/instance.rs` into `lyracore_shared::instance` so - /// the gateway and the module could not disagree about which maps are dungeons. A map-id set is a - /// world fact and entirely fine here — but the move quietly put a piece of module logic in a crate - /// this tripwire did not look at, so "no module game logic reads a shard id" would have stopped - /// being enforceable for anything that followed it there. It is scanned now. (Only THIS tripwire - /// is extended: the spatial-scan and character-owned tripwires are about `#[table]` definitions - /// and spatial iteration, neither of which exists in a `no_std`-shaped shared crate.) + /// The scan covers the Module's own `src` + `packages/*` plus `crates/lyracore-shared/src`, + /// which compiles INTO this Module and can name a database just as well. `DUNGEON_MAPS` lives + /// there, so Module logic that moves into the shared crate stays under this scan. The spatial + /// and character-owned scans need no extension, because a shared crate has no `#[table]` + /// definitions or spatial iteration. fn shard_id_scanned_files() -> Vec { let mut files = super::character_owned_tripwire::scanned_files(); let shared = repo_root().join("crates/lyracore-shared/src"); @@ -1355,14 +1342,12 @@ mod character_fence_tripwire { /// reaps that accessor — either via `reap!()` or a direct `ctx.db.()` call (the /// shape the ad-hoc blocks and the sweep fns `gc.rs` calls out to use). A bare substring match on /// the accessor NAME would be satisfied by a comment mentioning it (this file's own `gc.rs` carries -/// exactly that kind of comment about `game_creature_move_event`, explaining why it is no longer +/// exactly that kind of comment about `game_creature_move_event`, explaining why it is not /// reaped), so the check is deliberately narrower than "the name appears somewhere". /// -/// Caught on landing: `game_rest_state_event` (`rest.rs`) carries this exact shape and its own doc -/// comment calls it "a one-shot relay row with a GC TTL" (`transfer/transport.rs`'s -/// `NOT_TRANSPORTED` table), but no line in `gc.rs` ever reaped it — every rest-area threshold -/// crossing for the lifetime of a character left one more row behind. Fixed in the same change by -/// adding it to the `reap!` list. +/// `game_rest_state_event` (`rest.rs`) carries this exact shape. Its doc comment calls it "a +/// one-shot relay row with a GC TTL" (`transfer/transport.rs`'s `NOT_TRANSPORTED` table), so `gc.rs` +/// must reap it with `reap!`. Otherwise every rest-area threshold crossing leaves one more row behind. #[cfg(test)] mod gc_reap_tripwire { /// Accessors carrying the `id: u64` + `created_at: Timestamp` TTL shape that are deliberately @@ -1474,12 +1459,12 @@ mod gc_reap_tripwire { /// /// Nothing in the type system couples them: `grid_x`, `grid_y` and `cell` are three independent /// columns, and the census behind this tripwire found **24 independent write sites** across 8 files, with no -/// shared constructor for `game_world_entity` re-stamps (7 hand-rolled `e.grid_x =..` runs) or for +/// shared constructor for `game_world_entity` re-stamps (7 hand-rolled `e.grid_x = ..` runs) or for /// `game_gameobject` (9 independent struct literals). Adding a 25th and forgetting the third line /// compiles clean, passes every existing test, and shows up live as entities that vanish. /// -/// So: this scan requires every `grid_x` WRITE, a `grid_x:..` struct-literal field or a -/// `.grid_x =..` assignment, to have a `cell` write within the same short window of source +/// So: this scan requires every `grid_x` WRITE, a `grid_x: ..` struct-literal field or a +/// `.grid_x = ..` assignment, to have a `cell` write within the same short window of source /// text. It is deliberately textual and deliberately local: the point is to fail the build next to /// the line someone just wrote, not to prove a semantic property. #[cfg(test)] @@ -1536,7 +1521,7 @@ pub(crate) mod grid_cell_tripwire { false } - /// `impl` block a `Self {.. }` struct literal at `idx` resolves to. Walks backward for the + /// `impl` block a `Self { .. }` struct literal at `idx` resolves to. Walks backward for the /// nearest `impl` keyword, then takes `impl<..> TypeName` or, for a trait impl, the identifier /// after ` for ` (`impl Trait for TypeName`) — the same rule `Self` follows in the language. /// A near-clone of the `impl<..>` generics-skip is unavoidable here (no shared engine, unlike @@ -1584,10 +1569,10 @@ pub(crate) mod grid_cell_tripwire { /// Walks backward from `idx` tracking `{`/`}` depth to find the nearest enclosing brace, then /// takes the identifier immediately before it (skipping whitespace). Returns that identifier /// only if it looks like a type name (starts uppercase, matching this codebase's convention) — - /// `TypeName {.. }` or `TypeName {..prev }`, so `None` correctly falls out for a shorthand + /// `TypeName { .. }` or `TypeName { ..prev }`, so `None` correctly falls out for a shorthand /// `grid_x,` that is really an ordinary function-CALL argument (`queue_motion(.., grid_x, /// grid_y, ..)`: its nearest enclosing `{` is a `fn`/`if`/`match` body brace, not preceded by a - /// type name) or any other bare code block. A literal `Self {.. }` constructor resolves through + /// type name) or any other bare code block. A literal `Self { .. }` constructor resolves through /// [`resolve_self_type`] to the `impl` block's real type name instead of returning `"Self"` /// verbatim — `struct_has_cell_field` searches for `struct Self`, which never exists. fn enclosing_struct_literal_type(code: &str, idx: usize) -> Option<&str> { @@ -1635,7 +1620,7 @@ pub(crate) mod grid_cell_tripwire { None } - /// `true` if `code` (a single file's text) declares `struct TYPE_NAME {.. pub cell: i64.. }` + /// `true` if `code` (a single file's text) declares `struct TYPE_NAME { .. pub cell: i64 .. }` /// somewhere — i.e. `TYPE_NAME` is one of the cell-bearing rows this tripwire polices. Every /// cell-bearing type's struct literals in this codebase live in the same file as its own `#[table]` /// definition, so a same-file search is enough; a type this can't find (or that genuinely has no @@ -1697,9 +1682,9 @@ pub(crate) mod grid_cell_tripwire { /// a `cell` column (`enclosing_struct_literal_type` + `struct_has_cell_field` — excludes both /// ordinary function-call arguments, whose enclosing brace is a fn/if/match body not a type name, /// and the several event-log tables that carry `grid_x`/`grid_y` with no `cell` column at all, - /// by design). `enclosing_struct_literal_type` resolves a literal `Self {.. }` constructor + /// by design). `enclosing_struct_literal_type` resolves a literal `Self { .. }` constructor /// through `resolve_self_type` to the `impl` block's real type name, so a constructor written as - /// `Self {.. }` inside `impl E {.. }` is policed exactly like `E {.. }` would be. + /// `Self { .. }` inside `impl E { .. }` is policed exactly like `E { .. }` would be. fn grid_x_writes_missing_cell(code: &str) -> Vec { // Assembled at run time — a contiguous literal would match this file's own text. let field_init = format!("{}{}", "grid_x", ": "); diff --git a/module/src/world.rs b/module/src/world.rs index 2544da19d..558804bf5 100644 --- a/module/src/world.rs +++ b/module/src/world.rs @@ -339,7 +339,7 @@ pub struct WorldEntity { /// columns outright (`MAX_EXACT_INDEX_COLS`); range predicates are never index-served at all /// (`IndexProbe::Range` — "we currently never construct this variant") and an `OR` is evaluated /// row-by-row. So the four-column `by_grid` index is unreachable from SQL, and a - /// `grid_x BETWEEN.. AND grid_y BETWEEN..` box degrades to a full partition scan, 1.1 BILLION + /// `grid_x BETWEEN .. AND grid_y BETWEEN ..` box degrades to a full partition scan, 1.1 BILLION /// rows examined on `game_world_entity` in a 445-player measurement, 53% of all writer time. /// Folding the two grid columns into one makes `by_cell` a 3-column all-equality index, which the /// planner CAN serve, and the AOI box becomes 25 point probes instead of a scan. @@ -503,7 +503,7 @@ pub struct EntityMotion { /// columns outright (`MAX_EXACT_INDEX_COLS`); range predicates are never index-served at all /// (`IndexProbe::Range` — "we currently never construct this variant") and an `OR` is evaluated /// row-by-row. So the four-column `by_grid` index is unreachable from SQL, and a - /// `grid_x BETWEEN.. AND grid_y BETWEEN..` box degrades to a full partition scan, 1.1 BILLION + /// `grid_x BETWEEN .. AND grid_y BETWEEN ..` box degrades to a full partition scan, 1.1 BILLION /// rows examined on `game_world_entity` in a 445-player measurement, 53% of all writer time. /// Folding the two grid columns into one makes `by_cell` a 3-column all-equality index, which the /// planner CAN serve, and the AOI box becomes 25 point probes instead of a scan. @@ -1107,7 +1107,7 @@ pub(crate) fn apply_player_login( character.class, ); - // Talents (rank 27): re-apply every learned passive talent's aura at login (idempotent — refreshes by + // Talents: re-apply every learned passive talent's aura at login (idempotent — refreshes by // effect_id, never stacks). A character with no learned talents applies nothing (baseline-safe). crate::talent::apply_learned_talents(ctx, character.guid, owner, character.level as u32); @@ -1402,7 +1402,7 @@ pub(crate) fn plan_movement( } } -/// The movement core, actor-explicit (stage 4): everything the old sender-path `movement_update` +/// The movement core, actor-explicit: everything the old sender-path `movement_update` /// did after resolving WHO moved. The trusted gateway path (`gw::gw_movement_update`) delegates here /// — same shape as every `actor.rs` verb, factored out per that file's own rule for still-inlined cores. #[allow(clippy::too_many_arguments)] @@ -1640,7 +1640,7 @@ pub(crate) fn apply_movement_update( // Targeting // =========================================================================================== -/// The target-write core, actor-explicit (stage 4a) — `gw::gw_set_target` delegates here. +/// The target-write core, actor-explicit — `gw::gw_set_target` delegates here. pub(crate) fn apply_set_target( ctx: &ReducerContext, mut player: WorldEntity, @@ -1774,7 +1774,7 @@ pub(crate) fn do_repop(ctx: &ReducerContext, guid: u64) -> Result<(), String> { created_at: ctx.timestamp, reclaim_delay_micros: delay_micros, is_bones: false, - // 190 slice 2: the corpse stays in the instance the death happened in — the ghost + // The corpse stays in the instance the death happened in — the ghost // corpse-runs back through the portal (which re-binds them to this same instance). instance_id: player.instance_id, }); @@ -1802,11 +1802,11 @@ pub(crate) fn do_repop(ctx: &ReducerContext, guid: u64) -> Result<(), String> { // cmangos WorldSafeLocs + game_graveyard_zone), falling back to the nearest of every imported // graveyard on the map, falling back to the five hardcoded Elwynn/Westfall consts // (`graveyard::nearest`) when nothing is imported at all (this sandbox's default state): - // 105 Northshire Abbey (-8935, -188) , zone 12 Elwynn - // 106 Goldshire (-9339, 171) , zone 12 Elwynn - // 854 Eastvale Logging Camp (-9552, -1374) , zone 12 Elwynn - // ≈80 Sentinel Hill (-10650, 1180) [V] , zone 40 Westfall - // ≈81 Westfall coast (-11390, 1590) [V] (seed.rs also row-seeds these five, see 209) + // 105 Northshire Abbey (-8935, -188) zone 12 Elwynn + // 106 Goldshire (-9339, 171) zone 12 Elwynn + // 854 Eastvale Logging Camp (-9552, -1374) zone 12 Elwynn + // ≈80 Sentinel Hill (-10650, 1180) [V] zone 40 Westfall + // ≈81 Westfall coast (-11390, 1590) [V] (seed.rs also row-seeds these five) // The corpse was just inserted at (death_x, death_y) — use those coords + the player's map/race // to resolve the release point, then teleport_player emits MSG_MOVE_TELEPORT_ACK. let gy = graveyard::resolve_graveyard(ctx, death_map_id, death_x, death_y, death_race); @@ -1838,7 +1838,7 @@ pub(crate) fn persisted_pending_ghost(dead: bool, player_flags: u32, set_offline pub(crate) const RUN_SPEED_BP_1X: u32 = 10_000; /// What `(pending_godmode, pending_run_speed_mult_bp)` should be stamped onto the durable Character -/// row for this persist, the SCOPE DECISION of, in one pure function. +/// row for this persist, as one pure function. /// /// `!set_offline` (a cross-map teleport, a shard-transfer freeze, the stale-entity cleanup in /// `player_login`) CARRIES the live values, because the entity is about to be rebuilt from this row @@ -2017,7 +2017,7 @@ pub(crate) fn persist_entity(ctx: &ReducerContext, entity: &WorldEntity, set_off c.death_expire_micros = entity.death_expire_micros; c.pending_instance_id = entity.instance_id; - // Released-GHOST state survives an entity despawn (the 224 landmine): a + // Released-GHOST state survives an entity despawn: a // cross-map graveyard release persists-then-deletes the entity via `teleport_player`, and // without this stamp the WORLDPORT_ACK rebuild came back ALIVE with no ghost and (via // `player_login`'s corpse delete) no corpse — a silent free resurrect. Re-derived from the @@ -2352,7 +2352,7 @@ mod tests { assert!(!plan_movement(HEARTBEAT, false, false, false, 0.0, false, &stationary).moved); } - // ----: movement plausibility (detect-and-flag) ------------------------------ + // ---- movement plausibility (detect-and-flag) ------------------------------ #[test] fn movement_violation_flags_speed_and_teleport_but_not_legit_motion() { let run = lyracore_shared::constants::speeds::RUN; // 7.0 yd/s @@ -2381,7 +2381,7 @@ mod tests { ); } - // ----: cross-map teleport decision ------------------------------------------ + // ---- cross-map teleport decision ------------------------------------------ #[test] fn is_cross_map_teleport_only_when_target_map_differs() { @@ -2432,7 +2432,7 @@ mod tests { ); } - // ----: GM playtest state across a cross-map / cross-shard entity rebuild -------- + // ---- GM playtest state across a cross-map / cross-shard entity rebuild -------- #[test] fn gm_playtest_state_carries_across_a_map_change_and_resets_on_a_real_logout() { From f2b31932f0d388d66ee0777699e8b959a197bebd Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 15:49:28 +0200 Subject: [PATCH 18/21] test(transfer): name the export loop in the guid assertion message Co-Authored-By: Claude Sonnet 5.5 --- module/src/transfer/harness.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/module/src/transfer/harness.rs b/module/src/transfer/harness.rs index 6334e22fb..3f382ab23 100644 --- a/module/src/transfer/harness.rs +++ b/module/src/transfer/harness.rs @@ -51,7 +51,7 @@ pub struct FakeDb { group_members: RefCell>, /// Parties torn down by `remove_member`'s disband — what the character-owned DELETE sweep /// does to a party when one of its members is deleted, and precisely what - /// `detach_for_transfer` exists to run AHEAD of (AC). + /// `detach_for_transfer` exists to run AHEAD of. disbanded: RefCell>, /// `game_character_shard`: the forwarding receipt. CHARACTER-OWNED, so the cascade sweeps /// it — which is why `apply_finish` must record it AFTER the cascade, never before. @@ -1277,7 +1277,7 @@ fn a_corrupt_blob_is_refused_rather_than_half_applied() { } /// The export loop's OWN guards, driven directly: the guid it hands each mover, and the -/// machinery filter. (`export_rows` passing guid `0` to every mover was a survivor.) +/// machinery filter. A mutant that passes guid `0` to every mover must fail here. #[test] fn the_export_loop_hands_each_mover_the_transferring_guid() { let src = FakeDb::populated(GUID); @@ -1293,7 +1293,7 @@ fn the_export_loop_hands_each_mover_the_transferring_guid() { ); assert!( decoded::(&nobody, "harness_gear").is_empty(), - "the reaper must use the Escrow's Character guid; guid 0 owns no rows" + "the export loop must hand each mover the transferring guid; guid 0 owns no rows" ); assert!( !mine.iter().any(|t| t.table == "game_transfer_out"), From 5b1d4b7a34e0518346c7e94102750a9f5bf6ffa0 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 15:51:32 +0200 Subject: [PATCH 19/21] test(module): cover operator gates and the transfer fence through durable fixtures Phase 1 deleted source scans that pinned these facts. Each test now drives the reducers on a private Standalone. - operator_gates: every reducer in the published schema, except debug, scheduled, lifecycle and claim_operator, refuses an anonymous caller at the Operator gate. It replaces mail_operator, which covered the Mail reducers only. - transfer_fence: login, the timed-quest and rested passes, and the Instance reaper leave a Character in Escrow alone. - corpse_loot_residue, diminishing_returns, instance_party: a kill purges both corpse-loot residue tables, a control aura without a free slot leaves diminishing returns unchanged, and a solo Instance becomes the party's Instance. Standalone gains describe() for the schema read. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/lyracore-test-support/src/lib.rs | 12 ++ module/tests/corpse_loot_residue.rs | 36 +++++ module/tests/diminishing_returns.rs | 50 +++++++ module/tests/instance_party.rs | 58 ++++++++ module/tests/mail_operator.rs | 136 ------------------- module/tests/operator_gates.rs | 143 ++++++++++++++++++++ module/tests/support/mod.rs | 20 +++ module/tests/transfer_fence.rs | 170 ++++++++++++++++++++++++ 8 files changed, 489 insertions(+), 136 deletions(-) create mode 100644 module/tests/corpse_loot_residue.rs create mode 100644 module/tests/diminishing_returns.rs create mode 100644 module/tests/instance_party.rs delete mode 100644 module/tests/mail_operator.rs create mode 100644 module/tests/operator_gates.rs create mode 100644 module/tests/transfer_fence.rs diff --git a/crates/lyracore-test-support/src/lib.rs b/crates/lyracore-test-support/src/lib.rs index f23d76c48..6ace1aaf4 100644 --- a/crates/lyracore-test-support/src/lib.rs +++ b/crates/lyracore-test-support/src/lib.rs @@ -261,6 +261,18 @@ impl Standalone { self.publish(database, &["--bin-path", path.to_str().unwrap()], &[]); } + /// The published schema as `spacetime describe --json` prints it: tables, reducers and the + /// typespace their types refer into. + pub fn describe(&self) -> serde_json::Value { + let output = self + .command() + .args(["describe", "-s", &self.server, "--json", &self.database]) + .output() + .expect("failed to describe the database"); + self.assert_ok(&output); + serde_json::from_slice(&output.stdout).expect("the schema is not JSON") + } + pub fn call(&self, reducer: &str, args: &[&str]) -> Output { self.call_database(&self.database, reducer, args) } diff --git a/module/tests/corpse_loot_residue.rs b/module/tests/corpse_loot_residue.rs new file mode 100644 index 000000000..c11b5646b --- /dev/null +++ b/module/tests/corpse_loot_residue.rs @@ -0,0 +1,36 @@ +//! A kill starts the corpse's loot from nothing: rows a live creature's guid already carries, such +//! as pickpocket loot, never collide with the kill roll. + +mod support; + +use support::{wolves_beside, Standalone}; + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn a_kill_purges_loot_and_eligibility_left_on_the_living_creature() { + let mut shard = Standalone::start("corpse-loot-residue"); + shard.publish_module(); + shard.assert_call("claim_operator", &[]); + shard.assert_call("install_guid_range", &["0"]); + shard.assert_call("debug_seed_scenario_fixtures", &[]); + shard.assert_call("debug_spawn_player_entity", &["1"]); + let wolf = wolves_beside(&shard, "1", &[5.0]).remove(0); + shard.assert_sql(&format!( + "INSERT INTO game_corpse_loot (id, corpse_guid, slot, item_entry, count, quest_only, \ + reserved_for, designated_looter_guid, master_only, withheld, random_property_id) \ + VALUES (900001, {wolf}, 0, 2589, 1, false, 0, 0, false, false, 0)" + )); + shard.assert_sql(&format!( + "INSERT INTO game_corpse_loot_eligible (id, corpse_guid, eligible_guid) \ + VALUES (900002, {wolf}, 99)" + )); + + shard.assert_call("debug_kill_creature", &["1", &wolf]); + + assert!(shard + .query_rows("SELECT id FROM game_corpse_loot WHERE id = 900001") + .is_empty()); + assert!(shard + .query_rows("SELECT id FROM game_corpse_loot_eligible WHERE id = 900002") + .is_empty()); +} diff --git a/module/tests/diminishing_returns.rs b/module/tests/diminishing_returns.rs new file mode 100644 index 000000000..c8e355d23 --- /dev/null +++ b/module/tests/diminishing_returns.rs @@ -0,0 +1,50 @@ +//! Crowd-control diminishing returns advance only when the control aura lands on the target. + +mod support; + +use lyracore_shared::constants::tracer_spell::{AURA_SLOTS, BUFF_SLOT_COUNT}; +use support::{wolves_beside, Standalone}; + +/// The `Test Poly` fixture: 10 s of `A_CONTROL` with the polymorph mechanic. +const TEST_POLY: &str = "50023"; + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn a_control_aura_without_a_free_slot_leaves_diminishing_returns_unchanged() { + let mut shard = Standalone::start("diminishing-returns-refusal"); + shard.publish_module(); + shard.assert_call("claim_operator", &[]); + shard.assert_call("install_guid_range", &["0"]); + shard.assert_call("debug_seed_scenario_fixtures", &[]); + shard.assert_call("debug_spawn_player_entity", &["1"]); + // Two casters, so the second cast does not wait out the first caster's global cooldown. + let casters = wolves_beside(&shard, "1", &[3.0, 5.0]); + let debuff_slots = (AURA_SLOTS - BUFF_SLOT_COUNT).to_string(); + shard.assert_call( + "debug_fill_aura_slots", + &["1", &casters[0], "true", &debuff_slots], + ); + + shard.assert_call("debug_force_cast_at", &[&casters[0], TEST_POLY, "1"]); + assert!(polymorphs(&shard).is_empty()); + assert!(dr_levels(&shard).is_empty()); + + shard.assert_sql("DELETE FROM game_aura WHERE target_guid = 1"); + shard.assert_call("debug_force_cast_at", &[&casters[1], TEST_POLY, "1"]); + assert_eq!(polymorphs(&shard).len(), 1); + assert_eq!(dr_levels(&shard), ["1"]); +} + +fn polymorphs(shard: &Standalone) -> Vec { + shard.query_rows(&format!( + "SELECT id FROM game_aura WHERE target_guid = 1 AND spell_id = {TEST_POLY}" + )) +} + +fn dr_levels(shard: &Standalone) -> Vec { + shard + .query_rows("SELECT level FROM game_dr_state WHERE target_guid = 1") + .into_iter() + .map(|row| row["level"].clone()) + .collect() +} diff --git a/module/tests/instance_party.rs b/module/tests/instance_party.rs new file mode 100644 index 000000000..d93b72648 --- /dev/null +++ b/module/tests/instance_party.rs @@ -0,0 +1,58 @@ +//! A party formed after one member entered an Instance alone shares that Instance, so the members +//! who follow never mint a second one. + +mod support; + +use support::{actor, Standalone}; + +const OWNER: &str = "1"; +const MEMBER: &str = "2"; + +const INVITE: &str = "0"; +const ACCEPT: &str = "1"; + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn a_solo_instance_becomes_the_instance_of_the_party_its_owner_forms() { + let mut shard = Standalone::start("instance-party-adoption"); + shard.publish_module(); + shard.assert_call("claim_operator", &[]); + shard.assert_call("install_guid_range", &["0"]); + shard.assert_call( + "create_character", + &["1", "\"Member\"", "1", "1", "0", "0", "0", "0", "0", "0"], + ); + for guid in [OWNER, MEMBER] { + shard.assert_call("debug_spawn_player_entity", &[guid]); + } + shard.assert_call("debug_create_fixture_instance", &[OWNER]); + let solo = instance_of(&shard, OWNER); + + shard.assert_call( + "realm_group_op", + &[INVITE, &actor(OWNER), MEMBER, "0", "0", "0"], + ); + shard.assert_call( + "realm_group_op", + &[ACCEPT, &actor(MEMBER), "0", "0", "0", "0"], + ); + let group = shard.query_rows("SELECT group_id FROM game_group")[0]["group_id"].clone(); + + shard.assert_call("debug_create_fixture_instance", &[OWNER]); + shard.assert_call("debug_create_fixture_instance", &[MEMBER]); + + assert_eq!(instance_of(&shard, MEMBER), solo); + let instances: Vec<(String, String)> = shard + .query_rows("SELECT instance_id, party_id FROM game_instance") + .into_iter() + .map(|row| (row["instance_id"].clone(), row["party_id"].clone())) + .collect(); + assert_eq!(instances, [(solo, group)]); +} + +fn instance_of(shard: &Standalone, guid: &str) -> String { + shard.query_rows(&format!( + "SELECT instance_id FROM game_world_entity WHERE guid = {guid}" + ))[0]["instance_id"] + .clone() +} diff --git a/module/tests/mail_operator.rs b/module/tests/mail_operator.rs deleted file mode 100644 index b2bcf84e2..000000000 --- a/module/tests/mail_operator.rs +++ /dev/null @@ -1,136 +0,0 @@ -mod support; - -use support::{actor, Standalone}; - -#[test] -#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] -fn mail_requests_refuse_a_non_operator_before_touching_durable_state() { - let mut shard = Standalone::start("mail-operator-boundary"); - shard.publish_module(); - shard.assert_call("claim_operator", &[]); - shard.assert_call("install_guid_range", &["0"]); - shard.assert_call("debug_spawn_player_entity", &["1"]); - let request_actor = actor("1"); - let requests: &[(&str, &[&str])] = &[ - ("realm_mail_mark_read", &[&request_actor, "1"]), - ( - "realm_mail_send", - &[ - &request_actor, - "1", - "\"boundary letter\"", - "\"boundary letter\"", - "1", - "1", - "1", - "false", - ], - ), - ("realm_mail_take_item", &[&request_actor, "1"]), - ("realm_mail_take_money", &[&request_actor, "1"]), - ("realm_mail_item_room", &[&request_actor]), - ("realm_mail_delete", &[&request_actor, "1"]), - ("realm_mail_return", &[&request_actor, "1", "false"]), - ( - "realm_mail_fence", - &[ - "1", - &request_actor, - "1", - "\"boundary letter\"", - "\"boundary letter\"", - "1", - "1", - "1", - "1", - "1", - "false", - ], - ), - ( - "realm_mail_commit", - &[ - "1", - &request_actor, - "1", - "\"boundary letter\"", - "\"boundary letter\"", - "1", - "1", - "1", - "1", - "1", - "false", - "1", - "1", - "1", - "1", - "1", - "1", - "1", - "1", - ], - ), - ( - "realm_mail_take_money_fence", - &["1", &request_actor, "1", "1"], - ), - ("realm_mail_payout", &["1", &request_actor, "1", "1"]), - ( - "realm_mail_take_item_fence", - &["1", &request_actor, "1", "1"], - ), - ( - "realm_mail_item_payout", - &[ - "1", - &request_actor, - "1", - "1", - "1", - "1", - "1", - "false", - "1", - "1", - ], - ), - ("realm_mail_confirm_delivery", &["1", &request_actor]), - ("realm_mail_settle", &["1", &request_actor]), - ("realm_mail_copy_text", &[&request_actor, "1"]), - ("gw_mail_grant_letter", &[&request_actor, "1"]), - ("realm_mail_mark_letter_granted", &[&request_actor, "1"]), - ]; - let tables = [ - "game_mail", - "game_mail_escrow", - "game_item_instance", - "game_item_text", - ]; - let before: Vec<_> = tables - .iter() - .map(|table| shard.query_rows(&format!("SELECT * FROM {table}"))) - .collect(); - let purse = shard.query_rows("SELECT money FROM game_world_entity WHERE guid = 1"); - for (reducer, args) in requests { - let refused = shard.call_anonymous(reducer, args); - let text = format!( - "{}{}", - String::from_utf8_lossy(&refused.stdout), - String::from_utf8_lossy(&refused.stderr) - ); - assert!(!refused.status.success(), "{reducer}: {text}"); - assert!(text.contains("operator only"), "{reducer}: {text}"); - for (table, expected) in tables.iter().zip(&before) { - assert_eq!( - &shard.query_rows(&format!("SELECT * FROM {table}")), - expected, - "{reducer} changed {table}" - ); - } - assert_eq!( - shard.query_rows("SELECT money FROM game_world_entity WHERE guid = 1"), - purse - ); - } -} diff --git a/module/tests/operator_gates.rs b/module/tests/operator_gates.rs new file mode 100644 index 000000000..03dfba91f --- /dev/null +++ b/module/tests/operator_gates.rs @@ -0,0 +1,143 @@ +//! Every reducer the Gateway or the deploy CLI drives refuses a caller that is not the Operator. +//! The gated set comes from the published schema, so a new reducer is gated unless it is listed +//! here as open. + +mod support; + +use serde_json::{json, Map, Value}; +use support::Standalone; + +/// Open by design, beside the scheduled and lifecycle reducers the schema names. The first caller +/// of `claim_operator` becomes the Operator. +const OPEN: &[&str] = &["claim_operator"]; + +/// Debug reducers are a client-automation surface and accept any caller. +const OPEN_PREFIX: &str = "debug_"; + +/// The gate's refusal. The loot reducers map it to a client-facing code. +const GATE_REFUSALS: &[&str] = &["operator only", "loot:boundary_operator_rejected"]; + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn every_operator_reducer_refuses_an_anonymous_caller() { + let mut shard = Standalone::start("operator-gates"); + shard.publish_module(); + shard.assert_call("claim_operator", &[]); + shard.assert_call("install_guid_range", &["0"]); + // Arguments name this Character, so a missing gate meets a request it could act on. + shard.assert_call("debug_spawn_player_entity", &["1"]); + + let gated = operator_reducers(&shard.describe()); + for known in [ + "gw_player_login", + "realm_mail_send", + "set_realm_address", + "begin_transfer", + ] { + assert!( + gated.iter().any(|(name, _)| name == known), + "{known} is missing from the gated set" + ); + } + assert!(gated.iter().all(|(name, _)| name != "claim_operator")); + let claim = shard.call_anonymous("claim_operator", &[]); + let claim_text = output_text(&claim); + assert!( + !claim.status.success() && claim_text.contains("operator already claimed"), + "an open reducer answers an anonymous caller itself: {claim_text}" + ); + + let open: Vec = gated + .iter() + .filter_map(|(reducer, args)| { + let args: Vec<&str> = args.iter().map(String::as_str).collect(); + let output = shard.call_anonymous(reducer, &args); + let text = output_text(&output); + let refused = + !output.status.success() && GATE_REFUSALS.iter().any(|gate| text.contains(gate)); + (!refused).then(|| format!("{reducer}: {text}")) + }) + .collect(); + assert!( + open.is_empty(), + "{} of {} gated reducers let a non-Operator through:\n{}", + open.len(), + gated.len(), + open.join("\n") + ); +} + +/// Every reducer outside the open set, with one minimal JSON argument per parameter. +fn operator_reducers(schema: &Value) -> Vec<(String, Vec)> { + let typespace = schema["typespace"]["types"].as_array().expect("typespace"); + let scheduled: Vec<&str> = schema["tables"] + .as_array() + .expect("tables") + .iter() + .filter_map(|table| table["schedule"]["some"]["reducer_name"].as_str()) + .collect(); + schema["reducers"] + .as_array() + .expect("reducers") + .iter() + .filter(|reducer| reducer["lifecycle"].get("none").is_some()) + .filter_map(|reducer| { + let name = reducer["name"].as_str().expect("reducer name"); + let open = + OPEN.contains(&name) || name.starts_with(OPEN_PREFIX) || scheduled.contains(&name); + let args = reducer["params"]["elements"] + .as_array() + .expect("reducer params") + .iter() + .map(|param| minimal(¶m["algebraic_type"], typespace).to_string()) + .collect(); + (!open).then(|| (name.to_owned(), args)) + }) + .collect() +} + +/// The smallest value of a schema type that the CLI accepts. Integers are 1, the fixture +/// Character's guid; a sum takes its first variant. +fn minimal(ty: &Value, typespace: &[Value]) -> Value { + let (kind, inner) = ty + .as_object() + .and_then(|object| object.iter().next()) + .expect("an algebraic type"); + match kind.as_str() { + "Ref" => minimal(&typespace[inner.as_u64().unwrap() as usize], typespace), + "Product" => Value::Object( + inner["elements"] + .as_array() + .unwrap() + .iter() + .map(|field| { + let name = field["name"]["some"].as_str().expect("a named field"); + ( + name.to_owned(), + minimal(&field["algebraic_type"], typespace), + ) + }) + .collect::>(), + ), + "Sum" => { + let variant = &inner["variants"][0]; + let name = variant["name"]["some"].as_str().expect("a named variant"); + json!({ name: minimal(&variant["algebraic_type"], typespace) }) + } + "Array" => json!([]), + "String" => json!(""), + "Bool" => json!(false), + "F32" | "F64" => json!(0.0), + // An Identity is a formatted 256-bit integer. + "U256" | "I256" => json!("0x1"), + _ => json!(1), + } +} + +fn output_text(output: &std::process::Output) -> String { + format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) +} diff --git a/module/tests/support/mod.rs b/module/tests/support/mod.rs index ee4079caf..72f543012 100644 --- a/module/tests/support/mod.rs +++ b/module/tests/support/mod.rs @@ -37,6 +37,26 @@ pub fn wolves(shard: &mut Standalone, offsets: &[f32]) -> Vec { wolves } +/// Replace every wolf (entry 51000) with one per offset, in yards ahead of the live `character`. +/// Needs `debug_seed_scenario_fixtures`. Returns their guids in spawn order. +pub fn wolves_beside(shard: &Standalone, character: &str, offsets: &[f32]) -> Vec { + shard.assert_sql("DELETE FROM game_world_entity WHERE entry = 51000"); + for offset in offsets { + shard.assert_call( + "debug_spawn_at_feet", + &[character, "51000", &offset.to_string()], + ); + } + let mut guids: Vec = shard + .query_rows("SELECT guid FROM game_world_entity WHERE entry = 51000") + .iter() + .map(|wolf| number(wolf, "guid")) + .collect(); + assert_eq!(guids.len(), offsets.len(), "{guids:?}"); + guids.sort_unstable(); + guids.iter().map(u64::to_string).collect() +} + pub fn lone_wolf(shard: &mut Standalone) -> Row { wolves(shard, &[5.0]).remove(0) } diff --git a/module/tests/transfer_fence.rs b/module/tests/transfer_fence.rs new file mode 100644 index 000000000..554bef1be --- /dev/null +++ b/module/tests/transfer_fence.rs @@ -0,0 +1,170 @@ +//! A Character in Escrow stays frozen on its source Shard: login, the background passes and the +//! Instance reaper leave it alone until the Transfer settles. + +mod support; + +use std::time::Duration; + +use support::{actor, poll_until, Row, Standalone}; + +const ESCROWED: &str = "1"; +const BYSTANDER: &str = "2"; + +/// The rested pass fires once per 30 s window. +const REST_WINDOW_WAIT: Duration = Duration::from_secs(45); + +/// Character 1 in the world, and Character 2 on the same account when `bystander` is set. +fn start(name: &str, bystander: bool) -> Standalone { + let mut shard = Standalone::start(name); + shard.publish_module(); + shard.assert_call("claim_operator", &[]); + shard.assert_call("install_guid_range", &["0"]); + shard.assert_call("debug_spawn_player_entity", &[ESCROWED]); + if bystander { + shard.assert_call( + "create_character", + &["1", "\"Bystander\"", "1", "1", "0", "0", "0", "0", "0", "0"], + ); + let guid = &shard.query_rows("SELECT guid FROM game_character WHERE name = 'Bystander'")[0] + ["guid"]; + assert_eq!(guid, BYSTANDER); + shard.assert_call("debug_spawn_player_entity", &[BYSTANDER]); + } + shard +} + +fn begin_transfer(shard: &Standalone, guid: &str) { + shard.assert_call( + "begin_transfer", + &[ + guid, + &actor(guid), + "0", + "0", + "1200", + "1200", + "50", + "0", + "true", + ], + ); +} + +fn refused(shard: &Standalone, reducer: &str, args: &[&str], tag: &str) { + let output = shard.call(reducer, args); + let text = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + !output.status.success() && text.contains(tag), + "{reducer} should refuse with {tag}: {text}" + ); +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn login_refuses_a_character_in_escrow() { + let shard = start("transfer-fence-login", false); + let key = serde_json::to_string(&vec![7u8; 40]).unwrap(); + shard.assert_call( + "establish_session", + &["1", &key, r#"{"__identity__":"0x1"}"#], + ); + shard.assert_call("gw_heartbeat", &[]); + shard.assert_call("gw_player_login", &["1", &actor(ESCROWED)]); + + begin_transfer(&shard, ESCROWED); + refused( + &shard, + "gw_player_login", + &["1", &actor(ESCROWED)], + "in transit", + ); + assert!(shard + .query_rows("SELECT guid FROM game_world_entity WHERE guid = 1") + .is_empty()); +} + +/// The timed-quest and rested passes scan durable rows, not live entities, so each carries its +/// own fence. The Bystander shows both passes ran over the same rows. +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn background_passes_leave_a_character_in_escrow_unchanged() { + let shard = start("transfer-fence-background", true); + shard.assert_call("debug_seed_scenario_fixtures", &[]); + for guid in [ESCROWED, BYSTANDER] { + shard.assert_call("debug_grant_quest", &[guid, "50900"]); + } + begin_transfer(&shard, ESCROWED); + shard.assert_sql("UPDATE game_character_quest SET deadline_micros = 1"); + shard.assert_sql("UPDATE game_character SET resting = true, rested_since_micros = 1"); + + let passes_ran = poll_until(REST_WINDOW_WAIT, || { + quest(&shard, BYSTANDER)["failed"] == "true" && rest(&shard, BYSTANDER)["rested_xp"] != "0" + }); + assert!( + passes_ran, + "the Bystander's quest {:?} and rest {:?}", + quest(&shard, BYSTANDER), + rest(&shard, BYSTANDER) + ); + let escrowed_quest = quest(&shard, ESCROWED); + assert_eq!(escrowed_quest["failed"], "false"); + assert_eq!(escrowed_quest["deadline_micros"], "1"); + let escrowed_rest = rest(&shard, ESCROWED); + assert_eq!(escrowed_rest["rested_xp"], "0"); + assert_eq!(escrowed_rest["rested_since_micros"], "1"); +} + +fn quest(shard: &Standalone, guid: &str) -> Row { + shard.query_rows(&format!( + "SELECT failed, deadline_micros FROM game_character_quest WHERE character_guid = {guid}" + ))[0] + .clone() +} + +fn rest(shard: &Standalone, guid: &str) -> Row { + shard.query_rows(&format!( + "SELECT rested_xp, rested_since_micros FROM game_character WHERE guid = {guid}" + ))[0] + .clone() +} + +/// `debug_reap_instance` reads the same occupancy as the scheduled reaper, without its 30 min +/// empty window. +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn the_instance_reaper_holds_an_instance_claimed_by_escrow() { + let shard = start("transfer-fence-reaper", true); + for guid in [ESCROWED, BYSTANDER] { + shard.assert_call("debug_create_fixture_instance", &[guid]); + } + let claimed = instance_of(&shard, ESCROWED); + let empty = instance_of(&shard, BYSTANDER); + assert_ne!(claimed, empty); + shard.assert_call("debug_enter_instance", &[BYSTANDER, "0"]); + begin_transfer(&shard, ESCROWED); + + shard.assert_call("debug_reap_instance", &[&empty]); + refused( + &shard, + "debug_reap_instance", + &[&claimed], + "occupied or claimed", + ); + let instances: Vec = shard + .query_rows("SELECT instance_id FROM game_instance") + .into_iter() + .map(|row| row["instance_id"].clone()) + .collect(); + assert_eq!(instances, [claimed]); +} + +fn instance_of(shard: &Standalone, guid: &str) -> String { + shard.query_rows(&format!( + "SELECT instance_id FROM game_world_entity WHERE guid = {guid}" + ))[0]["instance_id"] + .clone() +} From eea47337618f21e075c863b7023b930c93206f99 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 15:59:06 +0200 Subject: [PATCH 20/21] docs(comments): drop work-item numbers from Rust comments Remove pre-NNN phrases and parenthesised work-item numbers. Keep spell, skill line, DBC field, update field and creature ids. No code change. Co-Authored-By: Claude Sonnet 5.5 --- crates/lyracore-shared/src/env.rs | 2 +- crates/lyracore-shared/src/packing.rs | 4 ++-- gateway/src/stdb/subscriptions.rs | 2 +- importer/src/collision.rs | 4 ++-- importer/src/dbc.rs | 4 ++-- importer/src/main.rs | 28 +++++++++++------------ importer/src/spell.rs | 20 ++++++++--------- importer/src/terrain.rs | 2 +- module/src/character.rs | 4 ++-- module/src/combat/engage.rs | 6 ++--- module/src/combat/folds.rs | 2 +- module/src/combat/swing.rs | 2 +- module/src/combat/tables.rs | 8 +++---- module/src/config.rs | 4 ++-- module/src/creatures/spawn.rs | 10 ++++----- module/src/creatures/tick/mod.rs | 2 +- module/src/debug/instance.rs | 4 ++-- module/src/debug/mod.rs | 2 +- module/src/gameobject.rs | 6 ++--- module/src/gc.rs | 2 +- module/src/graveyard.rs | 2 +- module/src/hooks.rs | 4 ++-- module/src/items/economy.rs | 2 +- module/src/items/tables.rs | 4 ++-- module/src/lib.rs | 2 +- module/src/loot/mod.rs | 10 ++++----- module/src/nav.rs | 4 ++-- module/src/professions.rs | 2 +- module/src/quest.rs | 2 +- module/src/reputation.rs | 6 ++--- module/src/rest.rs | 4 ++-- module/src/seed.rs | 2 +- module/src/seed/fixtures.rs | 2 +- module/src/skill.rs | 12 +++++----- module/src/spell/cast/resolve.rs | 8 +++---- module/src/spell/cast/targeting.rs | 14 ++++++------ module/src/spell/effects.rs | 2 +- module/src/spell/math.rs | 6 ++--- module/src/spell/scheduler.rs | 6 ++--- module/src/spell/spellbook.rs | 2 +- module/src/spell/tables.rs | 6 ++--- module/src/spell/taxonomy.rs | 6 ++--- module/src/spell/tests.rs | 4 ++-- module/src/stats.rs | 4 ++-- module/src/world.rs | 32 +++++++++++++-------------- module/src/xp.rs | 4 ++-- 46 files changed, 135 insertions(+), 135 deletions(-) diff --git a/crates/lyracore-shared/src/env.rs b/crates/lyracore-shared/src/env.rs index 62aa1f3bd..e4362eccd 100644 --- a/crates/lyracore-shared/src/env.rs +++ b/crates/lyracore-shared/src/env.rs @@ -1,4 +1,4 @@ -//! Environmental damage (058): fall damage from the client-reported airborne time. Shared by the +//! Environmental damage: fall damage from the client-reported airborne time. Shared by the //! module (applies the damage in `movement_update`) and the gateway (sends the matching //! `SMSG_ENVIRONMENTAL_DAMAGE_LOG` flavor line) so the two can never drift. diff --git a/crates/lyracore-shared/src/packing.rs b/crates/lyracore-shared/src/packing.rs index e986a008a..deaeb0f6c 100644 --- a/crates/lyracore-shared/src/packing.rs +++ b/crates/lyracore-shared/src/packing.rs @@ -18,7 +18,7 @@ pub const fn player_bytes(skin: u8, face: u8, hair_style: u8, hair_color: u8) -> /// NORMAL=0x02): byte 3 = 0 makes the 5875 client's `GetRestState()` return a nil exhaustion /// multiplier, crashing `MainMenuBar.lua` (the XP/rested bar) with an arithmetic-on-nil error. pub const REST_STATE_NORMAL: u8 = 0x02; -/// RESTED (196) — the client draws the zzz icon + turns the XP bar blue when byte 3 holds this. +/// RESTED — the client draws the zzz icon + turns the XP bar blue when byte 3 holds this. pub const REST_STATE_RESTED: u8 = 0x01; /// Pack `PLAYER_BYTES_2`: byte0 = facial hair, byte2 = owned bank bag slot count, byte3 = rest state @@ -27,7 +27,7 @@ pub const fn player_bytes_2(facial_hair: u8, bank_bag_slots: u8) -> u32 { player_bytes_2_with_rest(facial_hair, bank_bag_slots, false) } -/// Pack `PLAYER_BYTES_2` with an explicit rest state (196) — `resting` → RESTED byte (zzz + blue bar), +/// Pack `PLAYER_BYTES_2` with an explicit rest state — `resting` → RESTED byte (zzz + blue bar), /// else NORMAL. Used at spawn so a character who logged out in an inn logs back in already showing /// rested, and the purchased bank bag slot count shows without a relog. pub const fn player_bytes_2_with_rest(facial_hair: u8, bank_bag_slots: u8, resting: bool) -> u32 { diff --git a/gateway/src/stdb/subscriptions.rs b/gateway/src/stdb/subscriptions.rs index eda66165b..40c7ef0fa 100644 --- a/gateway/src/stdb/subscriptions.rs +++ b/gateway/src/stdb/subscriptions.rs @@ -7591,7 +7591,7 @@ mod tests { ); assert_eq!(msg.duration, 0); - // A non-facing leg (the pre-518 baseline) keeps the Normal variant unchanged. + // A non-facing leg keeps the Normal variant unchanged. let mut normal_row = row.clone(); normal_row.facing = false; normal_row.dur_ms = 500; diff --git a/importer/src/collision.rs b/importer/src/collision.rs index efb9cb748..d05160141 100644 --- a/importer/src/collision.rs +++ b/importer/src/collision.rs @@ -1,6 +1,6 @@ //! `--dump-collision `, SPIKE: prove we can read WMO/M2 -//! collision geometry out of the operator's own client MPQs before building the nav rasterizer -//! (241). Same licensing firewall as `--dbc`/`--terrain`: in-memory read, nothing written. +//! collision geometry out of the operator's own client MPQs before building the nav rasterizer. +//! Same licensing firewall as `--dbc`/`--terrain`: in-memory read, nothing written. //! //! Reads the ONE root ADT tile containing `--center`, lists its WMO (MODF) and doodad (MDDF) //! placements, then opens every referenced WMO root+group file and M2 model and reports diff --git a/importer/src/dbc.rs b/importer/src/dbc.rs index fedcd70bf..b2dd3a5f7 100644 --- a/importer/src/dbc.rs +++ b/importer/src/dbc.rs @@ -816,7 +816,7 @@ fn area_sql(table: &DbcAreaTable) -> (Vec, usize) { } /// Clear+reload SQL for `game_area_trigger` from `AreaTrigger.dbc`: the geometric -/// half of inn triggers (196), dungeon entrances (190), and quest explore objectives — a trigger +/// half of inn triggers, dungeon entrances, and quest explore objectives — a trigger /// volume is either a sphere (`radius`) or a box (`box_length`/`box_width`/`box_height`/`box_yaw`); /// the DBC carries both fields for every row regardless of which shape a given trigger actually /// uses. Loads ALL rows (small table — map filtering is a verify-time concern). No Timestamp → @@ -2359,7 +2359,7 @@ mod tests { #[test] fn skill_ability_sql_remaps_off_by_one_dbc_fields() { // Params are REAL semantics (min_skill, acquire_method, gray, green); the builder places - // them into the mis-named wow_dbc fields the corrected importer reads (282). Row 1: a + // them into the mis-named wow_dbc fields the corrected importer reads. Row 1: a // trained recipe (min 25, acquire 0, gray 75, green 30). Row 2: an autolearn ability // (acquire 1) with a trained-with-skill min. let table = DbcSkillLineAbility { diff --git a/importer/src/main.rs b/importer/src/main.rs index e442bb292..bd69e2eff 100644 --- a/importer/src/main.rs +++ b/importer/src/main.rs @@ -349,7 +349,7 @@ mod it { // already-verified BUY_PRICE=8 two columns later (Quality(5), Flags(6), BuyCount(7), BuyPrice(8) // is the standard cmangos 1.12 item_template column run). pub const FLAGS: usize = 6; - // BuyCount (080): the per-purchase stack a vendor hands over (water/food ×5, ammo ×200) — + // BuyCount: the per-purchase stack a vendor hands over (water/food ×5, ammo ×200) — // anchored between the verified Flags(6) and BUY_PRICE(8). pub const BUY_COUNT: usize = 7; pub const BUY_PRICE: usize = 8; @@ -606,7 +606,7 @@ fn gather_node(entry: u64) -> Option<(u32, u32, u32, u32)> { pub(crate) const GO_DOOR: u8 = 0; pub(crate) const GO_BUTTON: u8 = 1; -const GO_QUESTGIVER: u8 = lyracore_shared::constants::go_type::QUESTGIVER; // shared const (041) — no drift +const GO_QUESTGIVER: u8 = lyracore_shared::constants::go_type::QUESTGIVER; // shared const — no drift const GO_CHEST: u8 = 3; const GO_GOOBER: u8 = 10; const GO_MEETINGSTONE: u8 = lyracore_shared::constants::go_type::MEETINGSTONE; @@ -679,7 +679,7 @@ fn go_template_row( } /// DOOR/BUTTON's initial SPAWN state from the dump's own `data0` (cmangos `startOpen` [V]): nonzero → -/// spawns OPEN (state 1), else CLOSED (state 0, the pre-211 always-closed default). Every other type +/// spawns OPEN (state 1), else CLOSED (state 0). Every other type /// always spawns state 0 (CHEST closed/unlooted, GOOBER/GATHER/inert unused). Pure + fixture-tested. fn go_initial_state(stored_type: u8, data0: u32) -> u8 { if (stored_type == GO_DOOR || stored_type == GO_BUTTON) && data0 != 0 { @@ -1034,7 +1034,7 @@ pub(crate) struct Args { server: String, pub(crate) apply: bool, pub(crate) family: Option, - // clear+reload instead of the full ETL. `None` = full run (byte-identical to pre-216 behavior). + // clear+reload instead of the full ETL. `None` = full run. pub(crate) source_sha: String, // classic-db commit this dump came from. It is threaded into every `stamp_import_meta` call this // run makes. Empty string ("") when not given (no external provenance to record). @@ -1739,7 +1739,7 @@ fn distinct_race_class_combos(rows: &[(u32, u32)]) -> usize { /// Clear+reload SQL for `game_createinfo_spell` from cmangos `playercreateinfo_spell`. /// **FLIPS THE SOURCE FROM SEED TO IMPORT**: `module/src/seed.rs::seed_createinfo_spells` only inserts /// the hand-authored `CREATEINFO_KIT` rows if the table is EMPTY (the `game_start_position` / Talent.dbc -/// (207) precedent — an importer clear+reload REPLACES whatever is there, seed or a prior import; it +/// precedent — an importer clear+reload REPLACES whatever is there, seed or a prior import; it /// does not merge or coexist with it). The `id >= 0` DELETE is a tautology on the unsigned PK (mirrors /// 207's own phrasing) — it's a wholesale wipe, not a conditional one. A character created BEFORE this /// import keeps the `game_player_spell` rows ITS creation already copied (durable, per-character); only @@ -2168,7 +2168,7 @@ fn parse_npc_text_slots( } /// The first non-empty (male-then-female) text across all 8 slots — the back-compat single string -/// (`game_npc_text.text`, matching the pre-217 single-slot importer behavior), derived from the SAME +/// (`game_npc_text.text`), derived from the SAME /// parsed slots the multi-slot table uses, so the two can never disagree. fn slots_first_nonempty(slots: &[NpcTextSlot; 8]) -> String { for (male, female, _) in slots { @@ -2596,7 +2596,7 @@ fn parse_ref_pools(dump: &str) -> std::collections::HashMap>, @@ -2647,7 +2647,7 @@ fn resolve_ref_pool( /// (`resolve_ref_pool`, cycle-guarded), a FRESH `visited` set per referencing row, so the /// SAME pool resolves independently for every entry that references it (only a resolution's OWN /// recursion path is cycle-guarded, not the whole import). `quest_only` is cmangos's -/// `ChanceOrQuestChance < 0` marker, KEPT (not folded away like the pre-210 `.abs()`-only behavior) so +/// `ChanceOrQuestChance < 0` marker, KEPT (not folded away with `.abs()`) so /// the module can gate the roll on quest possession at roll time (see the module doc). A /// raw-chance-0 row is an empty placeholder → skipped. // ETL row shapes: each tuple is one parsed cmangos `*_loot_template` row, kept positional so the @@ -3453,7 +3453,7 @@ fn build_items_and_loot( startquest_nonzero_count += 1; } let bag_family: u32 = field(&row, it::BAG_FAMILY).parse().unwrap_or(0); - // BuyCount (080): floor at 1 — a 0 in the dump (or a parse miss) must not sell zero items. + // BuyCount: floor at 1 — a 0 in the dump (or a parse miss) must not sell zero items. let buy_count: u32 = field(&row, it::BUY_COUNT).parse().unwrap_or(1).max(1); let food_type: u8 = field(&row, it::FOOD_TYPE).parse().unwrap_or(0); // ON-USE SPELL OVERRIDE: a curated entry's spellid_1/spelltrigger_1 is OUR synthetic @@ -3866,7 +3866,7 @@ pub(crate) const FAMILIES: &[&str] = &[ const SPAWN_BATCH: usize = 250; /// Whether `name`'s DELETE+INSERT block should be pushed/applied this run: true when `--family` was -/// not given (full run — every family active, BYTE-IDENTICAL to pre-216 behavior) or when it names +/// not given (full run — every family active) or when it names /// exactly this family. pub(crate) fn family_active(args: &Args, name: &str) -> bool { match &args.family { @@ -4900,7 +4900,7 @@ fn collect_gameobject_spawns(dump: &str, scope: &WorldImportScope) -> Gameobject }) .collect(); // GOOBER entries (ALL, not just spawned) — kept for `build_quests`' USE_GAMEOBJECT objective gate - // below (unchanged behavior from pre-211: a quest referencing a non-GOOBER or unimported GO entry + // below (a quest referencing a non-GOOBER or unimported GO entry // is dropped rather than left stuck). let goober_entries: std::collections::HashSet = go_meta .iter() @@ -5368,7 +5368,7 @@ fn push_world_content_statements(args: &Args, content: MappedContent, stmts: &mu } if family_active(args, "gossip") { - // RESERVED RANGES (283): the ETL owns only the low id bands (it emits dense low ids); a + // RESERVED RANGES: the ETL owns only the low id bands (it emits dense low ids); a // PACKAGE that mints its own gossip (e.g. the dynamic-events guard) uses the high bands — // menu.entry ≥ 1_000_000 (above every real creature entry), and option.row_id / text_id / // slot.id ≥ 50_000 — so a routine import never wipes package rows. Documented in danger-zones. @@ -6154,7 +6154,7 @@ mod tests { // DOOR/BUTTON with a nonzero startOpen (dump data0) spawn OPEN (state 1). assert_eq!(go_initial_state(GO_DOOR, 1), 1); assert_eq!(go_initial_state(GO_BUTTON, 1), 1); - // DOOR/BUTTON with startOpen == 0 spawn CLOSED (state 0) — the pre-211 default. + // DOOR/BUTTON with startOpen == 0 spawn CLOSED (state 0). assert_eq!(go_initial_state(GO_DOOR, 0), 0); assert_eq!(go_initial_state(GO_BUTTON, 0), 0); // Every OTHER type always spawns state 0 regardless of data0 (data0 means something else there @@ -6553,7 +6553,7 @@ mod tests { /// A template with no resistance/spell-3-5/skill/rep/maxcount/flags/page_text/startquest/ /// bag_family data at all keeps every column at 0, the additive-migration - /// baseline (byte-identical to the pre-213 shape for an ordinary existing item like the starter + /// baseline (unchanged for an ordinary existing item like the starter /// Worn Shortsword). Built the same index-constant-driven way as the row above, so a real /// no-frills weapon row round-trips with every new column zeroed. #[test] diff --git a/importer/src/spell.rs b/importer/src/spell.rs index 2577a23a5..72e5e38bf 100644 --- a/importer/src/spell.rs +++ b/importer/src/spell.rs @@ -186,7 +186,7 @@ const SPEED_MOUNTED: i32 = 3; // ModIncreaseMountedSpeed family; T4 folds this s // form 19 Berserker → stance 2 form 8 Dire Bear → stance 5 // Unmapped forms (Aquatic 4 / Travel 3 / Tree 2 / Ghoul 7 / Moonkin 31 / …) stay out of scope: their // mask bits are DROPPED (a spell usable ONLY in an unmapped form imports with the mapped-form bits it -// has, or 0 = "any stance" — the pre-156 behavior for every non-warrior form). +// has, or 0 = "any stance"). const FORM_CAT: i32 = 1; const FORM_BEAR: i32 = 5; const FORM_DIRE_BEAR: i32 = 8; @@ -502,7 +502,7 @@ fn correct_script_effect_kind(name: &str, kind: u8) -> u8 { // carrying the heal in base_points — and fell to E_SCRIPTED (a hollow no-op "heal") without this. "Life Tap" => E_CONVERT_RESOURCE, // health -> mana, 1:1 "Charge" => E_CHARGE, // the rush effect (vanilla effect 96) -> teleport-to-target - "Blink" => E_BLINK, // Mage Blink (116): the dead SCRIPT teleport effect (raw 29) -> teleport the caster ~20yd FORWARD along its facing. Name-rescued like Charge (raw 29 is a generic teleport with per-spell destination rules; Blink's is "forward"). eff2 (root/snare A_IMMUNITY) already maps natively. + "Blink" => E_BLINK, // Mage Blink: the dead SCRIPT teleport effect (raw 29) -> teleport the caster ~20yd FORWARD along its facing. Name-rescued like Charge (raw 29 is a generic teleport with per-spell destination rules; Blink's is "forward"). eff2 (root/snare A_IMMUNITY) already maps natively. "Seal of Righteousness" => A_SEAL, // the inert A_FLAG marker IS a proc-on-swing holy seal "Stealth" => A_STEALTH, // the inert A_FLAG marker IS the stealth presence (creatures skip it; broken on action) "Retaliation" => A_RETALIATE, // the inert A_FLAG marker IS the free-counter-swing self-buff (any melee attacker gets swung back at) @@ -636,7 +636,7 @@ fn aura_mod_to_kind(aura: AuraMod) -> u8 { // stat — FLAT (ModStat) vs PERCENT (ModPercentStat / ModTotalStatPercentage). The percent ones fold // as a multiplier in recompute_vitals (The Human Spirit = +5% Spirit), so they need a distinct kind. - // Spell modifiers (264): the talent-passive class (Improved Fireball's cast-time cut, + // Spell modifiers: the talent-passive class (Improved Fireball's cast-time cut, // fire-damage-% talents). Op rides EffectMiscValue -> p0 (P_SPELLMOD_OP); the affected-spell // family mask rides EffectItemType -> p1. The engine folds them at the cast-time/damage seams. AddFlatModifier => A_SPELLMOD_FLAT, @@ -1672,7 +1672,7 @@ fn resolve_effect_kind( // DoT. The DBC encodes it as A_PERIODIC_DAMAGE with a dynobj/self target that resolves WRONG — // Consecration → T_SELF would DoT the paladin himself. Reclassify BY NAME to E_PERSISTENT_AREA // (the Charge/Blink name-rescue precedent) so it spawns a game_ground_area whose own - // tick_ground_areas damages hostiles inside. Consecration is caster-anchored; Flamestrike (262) + // tick_ground_areas damages hostiles inside. Consecration is caster-anchored; Flamestrike // is the first CLICKED-GROUND one: the ground-target dest plumbing anchors the area at // the click when the cast carries a DEST_LOCATION block, so the same kind serves both. // Blizzard/Rain of Fire remain un-rescued (channeled patches — their channel/tick interplay is @@ -1810,7 +1810,7 @@ fn trainer_offering_rows( trainer_rows } -/// Fishing (060): the E_FISH marker effect rows for the three tier ids (skill 356 — 7620/7731/7732, +/// Fishing: the E_FISH marker effect rows for the three tier ids (skill 356 — 7620/7731/7732, /// PROFESSION_LEARN's fishing entry). The gateway routes CMSG_CAST_SPELL to the `fish` reducer by /// this KIND (the enchant-route pattern — never a spell-id list). These are OUR OWN taxonomy rows /// (not client data, firewall-clean), written by key AFTER the bulk insert so they replace whatever @@ -1852,7 +1852,7 @@ fn assemble_spell_sql( "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", &rows.effects.iter().map(SpellEffectRow::sql_values).collect::>(), ); - // Reagents (282): same clear-guard as the spell/effect tables (spell_delete_statements only + // Reagents: same clear-guard as the spell/effect tables (spell_delete_statements only // wipes < SYNTHETIC_SPELL_ID_FLOOR, so hand/test-fixture reagents survive a curated reload). push_insert( &mut stmts, @@ -1916,7 +1916,7 @@ fn build_spell_sql( Ok((sql, cov, samples)) } -/// Reserved `game_trainer_spell` id range for the CURATED override rows (259): far above the --dump +/// Reserved `game_trainer_spell` id range for the CURATED override rows: far above the --dump /// ETL's explicit ids (~4300) and disjoint from every other reserved range. The reload deletes this /// whole span by id, so a shrunken override list leaves no stale rows behind. const CURATED_TRAINER_ID_BASE: u64 = 5_200_000; @@ -1934,7 +1934,7 @@ fn spell_delete_statements(only: &[u32]) -> Vec { return vec![ format!("DELETE FROM game_spell WHERE spell_id < {SYNTHETIC_SPELL_ID_FLOOR}"), format!("DELETE FROM game_spell_effect WHERE spell_id < {SYNTHETIC_SPELL_ID_FLOOR}"), - // Reagents (282) share the fixture-floor guard so hand/test reagents survive a reload. + // Reagents share the fixture-floor guard so hand/test reagents survive a reload. format!("DELETE FROM game_spell_reagent WHERE spell_id < {SYNTHETIC_SPELL_ID_FLOOR}"), format!("DELETE FROM game_creature_ai_spell_metadata WHERE spell_id < {SYNTHETIC_SPELL_ID_FLOOR}"), ]; @@ -3150,7 +3150,7 @@ mod tests { E_SET_STANCE ); assert_eq!(correct_script_effect_kind("Cat Form", A_FLAG), E_SET_STANCE); - // Non-combat forms are NOT rescued — their marker stays the inert A_FLAG (the pre-156 shape). + // Non-combat forms are NOT rescued — their marker stays the inert A_FLAG. assert_eq!(correct_script_effect_kind("Aquatic Form", A_FLAG), A_FLAG); assert_eq!(correct_script_effect_kind("Travel Form", A_FLAG), A_FLAG); assert_eq!(correct_script_effect_kind("Moonkin Form", A_FLAG), A_FLAG); @@ -3694,7 +3694,7 @@ mod tests { ); assert_eq!(resolve_aura_params(A_MOD_STAT, ModStat, 3), (3, P_STAT_ID)); // INT // ModStat is FLAT; ModTotalStatPercentage / ModPercentStat are PERCENT (distinct kind). BOTH resolve - // the stat from misc — a SPECIFIC stat (4=Spirit, The Human Spirit), not a blanket all-stats (104). + // the stat from misc — a SPECIFIC stat (4=Spirit, The Human Spirit), not a blanket all-stats. assert_eq!(aura_mod_to_kind(ModStat), A_MOD_STAT); assert_eq!(aura_mod_to_kind(ModTotalStatPercentage), A_MOD_STAT_PCT); assert_eq!( diff --git a/importer/src/terrain.rs b/importer/src/terrain.rs index 63be04cbc..8064bfbcf 100644 --- a/importer/src/terrain.rs +++ b/importer/src/terrain.rs @@ -468,7 +468,7 @@ mod tests { err36.contains("stacked instance floors"), "the map-36 refusal names the floor representation limit: {err36}" ); - // Any other unknown map still fails loud (the pre-226 behavior, message widened). + // Any other unknown map still fails loud. assert!(super::map_dir(429).is_err()); } diff --git a/module/src/character.rs b/module/src/character.rs index 650ec5a1d..3599a6d33 100644 --- a/module/src/character.rs +++ b/module/src/character.rs @@ -117,14 +117,14 @@ pub struct Character { #[default(false)] pub pending_ghost: bool, - /// Rest state (196): logged out in a rest area (inn/city)? Stamped by `persist_entity` from the live + /// Rest state: logged out in a rest area (inn/city)? Stamped by `persist_entity` from the live /// entity's `resting` flag at logout, read by `player_login` to pick the offline rested rate (full in /// a rest area vs 1/4 in the field) and to spawn the character already showing the zzz/blue-bar byte. /// `#[default(false)]` + END-appended → auto-migrates. Gateway-subscribed (`game_character`) → /// hand-synced in `character_type.rs` + `schema_parity.rs` (the `gm_level`/`pending_ghost` precedent). #[default(false)] pub resting: bool, - /// Live-accrual clock (196): unix-epoch micros from which un-materialized ONLINE rested time is + /// Live-accrual clock: unix-epoch micros from which un-materialized ONLINE rested time is /// counted; 0 = not live-accruing (offline, or not in a rest area). The `rested_accrue_pass` tick /// grows `rested_xp` from this stamp and re-stamps it once ≥1 XP banks (lossless). `#[default(0u64)]` /// — TYPED (a bare `0` is a 4-byte i32 → the u64-column migration rejects it, `last_logout_micros` diff --git a/module/src/combat/engage.rs b/module/src/combat/engage.rs index 8f3b901cf..2456f4929 100644 --- a/module/src/combat/engage.rs +++ b/module/src/combat/engage.rs @@ -398,7 +398,7 @@ pub struct CombatEvent { /// the client renders the arrow projectile. END-appended + `#[default(0)]` → auto-migrates. [event] #[default(0)] pub ammo_display_id: u32, - /// True when a queued on-next-swing spell (Heroic Strike/Cleave) FIRED on this landed swing (114): + /// True when a queued on-next-swing spell (Heroic Strike/Cleave) FIRED on this landed swing: /// vanilla REPLACES the white hit — the whole swing is the spell (one yellow named line, carried by /// the SpellCastEvent the swing inserts). The gateway then SKIPS the SMSG_ATTACKERSTATEUPDATE for /// this row (killing_blow/ATTACKSTOP still honored); `damage` keeps the true total for QA readers. @@ -460,7 +460,7 @@ pub struct MeleeSchedule { pub scheduled_at: ScheduleAt, } -/// One in-flight RANGED projectile (097): scheduled at fire + travel time; `ranged_impact` then +/// One in-flight RANGED projectile: scheduled at fire + travel time; `ranged_impact` then /// applies the frozen post-mitigation damage (health/lethal/threat/rage/skill/combat-flag) so the /// server-side hit lands when the client's arrow does. The final damage and its client log are both /// committed at impact, after fresh lethal-floor and health checks. Module-private. @@ -786,7 +786,7 @@ pub(crate) fn apply_start_ranged_attack( // LAND-MOUNT dismount (22): the ranged twin of the melee hook — after the last activation gate, // before the engagement is armed, so a refused activation leaves the mount up. crate::mount::dismount(ctx, attacker.guid); - // Initial-shot wind-up (097): a ranged auto-attack must NOT fire instantly on activation — vanilla's + // Initial-shot wind-up: a ranged auto-attack must NOT fire instantly on activation — vanilla's // Auto Shot has a ~0.5s cast before the first shot (the user: "we shoot right away, no waiting for the // attack timer"). `last_swing_ms == 0` would make the swing tick fire THIS tick; instead seed it so the // first shot is `RANGED_INITIAL_SHOT_MS` out. The swing gate fires when `now - last_swing >= delay`, so diff --git a/module/src/combat/folds.rs b/module/src/combat/folds.rs index 71786cd0e..a5a0c4509 100644 --- a/module/src/combat/folds.rs +++ b/module/src/combat/folds.rs @@ -100,7 +100,7 @@ pub(crate) fn aura_attack_power_bonus(ctx: &ReducerContext, unit_guid: u64) -> u /// Holy damage a unit's active SEAL aura adds to a LANDED melee swing (Seal of Righteousness): the summed /// `A_SEAL` aura value, weapon-speed-weighted — vanilla SoR is per-swing, scaled by weapon speed. `(0, 0)` /// for a unit with no seal (baseline-safe). Pull model — the swing READS the seal aura, no per-spell code. -/// Also returns the seal's SPELL id so the swing can name the yellow proc line after it (114). +/// Also returns the seal's SPELL id so the swing can name the yellow proc line after it. pub(crate) fn seal_holy_on_swing(ctx: &ReducerContext, attacker: &WorldEntity) -> (u32, u32) { let (amount, spell) = crate::spell::seal_amount_and_spell(ctx, attacker.guid); (seal_swing_holy(amount, attacker.base_attack_time_ms), spell) diff --git a/module/src/combat/swing.rs b/module/src/combat/swing.rs index 380666dce..82d0ff724 100644 --- a/module/src/combat/swing.rs +++ b/module/src/combat/swing.rs @@ -503,7 +503,7 @@ fn swing_blocked( // within the 5 yd 3D reach of a player just inside it (live find: the Rogue Wizard // beat a wall-separated player to death by melee). One LoS ray per due swing, both // directions symmetric (players can't hit through walls either). `has_los` is `true` - // whenever nav is off — byte-identical pre-243 combat. + // whenever nav is off. if !crate::nav::has_los( ctx, attacker.map_id, diff --git a/module/src/combat/tables.rs b/module/src/combat/tables.rs index 07031ea2d..f8d2d6132 100644 --- a/module/src/combat/tables.rs +++ b/module/src/combat/tables.rs @@ -31,16 +31,16 @@ pub(crate) const MELEE_RANGE_LEEWAY_SQ: f32 = // standstill does not (no leeway leak into stationary combat). pub(crate) const MELEE_RANGE_LEEWAY_CREATURE_SQ: f32 = (5.0 + 8.0 / 3.0) * (5.0 + 8.0 / 3.0); // (7.67 yd)² pub(crate) const MELEE_LEEWAY_WINDOW_MS: u32 = 1200; -pub(crate) const RANGED_RANGE_SQ: f32 = 1225.0; // (35 yd)² — ranged auto-attack max reach; inside MELEE_RANGE_SQ is "too close" (shot hard-fails, 097) -pub(crate) const RANGED_INITIAL_SHOT_MS: u32 = 500; // Auto Shot wind-up before the FIRST shot (vanilla's 0.5s RANGED_ATTACK timer re-arm), vs firing instantly on activation (097) +pub(crate) const RANGED_RANGE_SQ: f32 = 1225.0; // (35 yd)² — ranged auto-attack max reach; inside MELEE_RANGE_SQ is "too close" (shot hard-fails) +pub(crate) const RANGED_INITIAL_SHOT_MS: u32 = 500; // Auto Shot wind-up before the FIRST shot (vanilla's 0.5s RANGED_ATTACK timer re-arm), vs firing instantly on activation // Projectile speeds from the real 1.12 Spell.dbc (speed column, calibrated on Fireball=24.0): // spell 75 Auto Shot = 40 yd/s, spell 5019 wand Shoot = 20 yd/s. Drive the shot's damage-at-impact - // delay (dist/speed) so the hit lands with the client's arrow, not at the muzzle (097). + // delay (dist/speed) so the hit lands with the client's arrow, not at the muzzle. pub(crate) const AUTO_SHOT_PROJECTILE_SPEED: f32 = 40.0; pub(crate) const WAND_PROJECTILE_SPEED: f32 = 20.0; // The 1.12 MovementFlags bits that mean "actually translating" (vmangos movementFlagsMask minus the // turn/pitch bits): forward|backward|strafe L/R|jumping|falling-far. Turning in place must NOT -// count — vanilla keeps Auto Shot firing while you spin, but DEFERS shots while you move (097). +// count — vanilla keeps Auto Shot firing while you spin, but DEFERS shots while you move. pub(crate) const MOVE_MASK_MOVING: u32 = 0x1 | 0x2 | 0x4 | 0x8 | 0x2000 | 0x4000; /// The single forward bit the SERVER sets on a unit it moves itself (vanilla `MOVEFLAG_FORWARD`, /// which its own spline launch records on the unit). A client-driven unit sends its own flags on diff --git a/module/src/config.rs b/module/src/config.rs index 8b94fec21..0ed1945bd 100644 --- a/module/src/config.rs +++ b/module/src/config.rs @@ -150,8 +150,8 @@ pub struct GameArea { /// `box_length`/`box_width`/`box_height`/`box_yaw`; vanilla trigger definitions use one shape or the /// other, never both). Loaded from the client `AreaTrigger.dbc` by the importer's `--dbc` mode /// (see `importer/src/dbc.rs::area_trigger_sql`). The geometric half of inn triggers -/// (196 — "make this inn your home" needs the player standing inside the inn's trigger volume), -/// dungeon entrances (190), and quest explore objectives. No Timestamp → plain SQL. [static] +/// ("make this inn your home" needs the player standing inside the inn's trigger volume), +/// dungeon entrances, and quest explore objectives. No Timestamp → plain SQL. [static] #[table(accessor = game_area_trigger, public)] pub struct GameAreaTrigger { #[primary_key] diff --git a/module/src/creatures/spawn.rs b/module/src/creatures/spawn.rs index fcf360086..9f4b555fe 100644 --- a/module/src/creatures/spawn.rs +++ b/module/src/creatures/spawn.rs @@ -85,7 +85,7 @@ pub struct CreatureTemplate { // entry (collapsed like `LootId`); `skin_loot_id` keys `game_skinning_loot` (NOT collapsed — many // creatures of the same level band share one skin table). 0 on either ⇒ "not imported" — E_PICKPOCKET // grants copper only (unchanged) and `skin_corpse` falls back to the flat Light Leather — so every - // existing row (imported pre-210, or seeded) auto-migrates byte-identical. `#[default(0u32)]` + + // existing row auto-migrates byte-identical. `#[default(0u32)]` + // end-appended (migration rule). #[default(0u32)] pub pickpocket_loot_id: u32, @@ -252,9 +252,9 @@ pub struct NpcText { /// its male line DUPLICATES `NpcText.text` (a reader who only touches this table still gets a /// self-contained slot 0; when slot 0 is empty, `NpcText.text` instead carries the first NON-empty /// slot for back-compat and no slot-0 row exists here — empty slots are never emitted). A -/// `text_id` with NO rows here at all (an existing pre-217 row, or any id nobody has re-imported +/// `text_id` with NO rows here at all (an existing row, or any id nobody has re-imported /// since) is read by the gateway as "legacy single-slot": `NpcText.text` in both genders at -/// probability 1.0, every other slot silent — byte-identical to pre-217 behavior. Importer-owned +/// probability 1.0, every other slot silent. Importer-owned /// (clear+reload). No Timestamp → SQL-seedable. [static] #[table(accessor = game_npc_text_slot, public, index(accessor = by_text_id, btree(columns = [text_id])))] pub struct NpcTextSlot { @@ -725,7 +725,7 @@ pub fn build_creature_entity( instance_id, // every caller passes 0 (open world) run_speed_mult_bp: 10_000, // 1× — GM `.speed` targets players only godmode: false, // GM `.god` targets players only - resting: false, // creatures never rest (196) + resting: false, // creatures never rest // A creature never calls `recompute_sheet`, so its sheet fields stay 0. sheet_str_bonus: 0, sheet_agi_bonus: 0, @@ -824,7 +824,7 @@ pub fn build_player_entity( character.hair_style, character.hair_color, ), - // Rest state (196): bake the RESTED byte if this character logged out in an inn, so it logs + // Rest state: bake the RESTED byte if this character logged out in an inn, so it logs // back in already showing the zzz icon + blue XP bar (no post-login relay needed). Byte 2 // carries the persisted bank bag slot count so a purchase survives logout without a relog. player_bytes_2: packing::player_bytes_2_with_rest( diff --git a/module/src/creatures/tick/mod.rs b/module/src/creatures/tick/mod.rs index 05c35f7ec..7d0135454 100644 --- a/module/src/creatures/tick/mod.rs +++ b/module/src/creatures/tick/mod.rs @@ -149,7 +149,7 @@ pub struct CreatureSpline { /// (`SMSG_MONSTER_MOVE`'s `FacingAngle` variant). A stationary stand-and-swing creature never /// throws a normal leg (nothing to interpolate), so without this the client never learns its /// heading changed — the "keeps its pre-combat orientation until you move" bug. `false`/`0.0` - /// (the pre-518 baseline) reproduces the old `Normal`-type stop exactly, so every other caller + /// reproduces the `Normal`-type stop exactly, so every other caller /// of [`emit_move_spline`] is unaffected. END-appended + defaulted (migration rule). #[default(false)] pub facing: bool, diff --git a/module/src/debug/instance.rs b/module/src/debug/instance.rs index 88024db34..bbe8b6742 100644 --- a/module/src/debug/instance.rs +++ b/module/src/debug/instance.rs @@ -143,7 +143,7 @@ pub fn debug_nav_probe(ctx: &ReducerContext, map: u32, x: f32, y: f32) { } /// Toggle nav-grid consumption (`game_config.nav_enabled`), upserts row 0 like -/// `debug_set_xp_rate`. OFF = pre-243 straight-line movement + wall-blind aggro/casts. +/// `debug_set_xp_rate`. OFF = straight-line movement + wall-blind aggro/casts. #[reducer] pub fn debug_set_nav_enabled(ctx: &ReducerContext, enabled: bool) -> Result<(), String> { let cfg = ctx.db.game_config(); @@ -1058,7 +1058,7 @@ pub fn debug_grant_default_actions( Ok(()) } -/// One-time backfill (246): stamp grid_x/grid_y on every existing gameobject row from its (x, y) +/// One-time backfill: stamp grid_x/grid_y on every existing gameobject row from its (x, y) /// — imported/seeded rows predate the columns. Idempotent. #[reducer] pub fn debug_backfill_go_grid(ctx: &ReducerContext) { diff --git a/module/src/debug/mod.rs b/module/src/debug/mod.rs index 3f2c4bbf9..129135676 100644 --- a/module/src/debug/mod.rs +++ b/module/src/debug/mod.rs @@ -1214,7 +1214,7 @@ pub fn debug_fill_aura_slots( Ok(()) } -/// Log the reputation vendor discount (195) `player_guid` gets at a creature whose FactionTemplate is +/// Log the reputation vendor discount `player_guid` gets at a creature whose FactionTemplate is /// `faction_template_id` — runs the real `vendor_discount_pct` resolution against live data (server-side /// proof without needing to drive a full buy, whose vendor guid >2^53 mangles through `spacetime call`). #[reducer] diff --git a/module/src/gameobject.rs b/module/src/gameobject.rs index 97d661b61..7e3845401 100644 --- a/module/src/gameobject.rs +++ b/module/src/gameobject.rs @@ -208,7 +208,7 @@ pub struct GameObject { /// hand-synced in the SAME change (playbook failure-mode). #[default(0u64)] pub instance_id: u64, - /// AOI grid cell (246): stamped from (x, y) at insert — GOs are static, no re-stamp. The + /// AOI grid cell: stamped from (x, y) at insert — GOs are static, no re-stamp. The /// per-player GO subscription scopes to the viewer's grid box exactly like game_world_entity /// (the un-scoped table made the client draw all ~4.6k zone GOs at once — the felt frame /// hitch). END-appended + defaulted → auto-migrates; backfill via debug_backfill_go_grid. @@ -600,8 +600,8 @@ pub(crate) fn respawn_due(state: u8, respawn_at_micros: u64, now: u64) -> bool { /// Does the lock `lock_id` require opening before a CHEST/GOOBER/DOOR use? True iff `game_lock` /// holds a REAL opener row for it, either a skill line to meet or a key item. A lock -/// with ONLY degenerate `property==0` slots (unmapped LockTypes = open-by-hand) is NOT gated: it opened -/// freely pre-119 and still does, so widening the import's lock coverage never bricks a hand-open chest. +/// with ONLY degenerate `property==0` slots (unmapped LockTypes = open-by-hand) is NOT gated: it opens +/// freely, so widening the import's lock coverage never bricks a hand-open chest. fn lock_requires_opening(ctx: &ReducerContext, lock_id: u32) -> bool { ctx.db .game_lock() diff --git a/module/src/gc.rs b/module/src/gc.rs index 5c33ae986..5c9e1457c 100644 --- a/module/src/gc.rs +++ b/module/src/gc.rs @@ -94,7 +94,7 @@ pub fn reap_movement_events(ctx: &ReducerContext, _schedule: EventReaperSchedule reap!(game_bot_invite_intent); // bot-decided invites awaiting gateway pickup reap!(game_auction_notice); // live outbid/won/sold/expired/new-bid notices to an online seller or bidder reap!(game_movement_violation); // recent anti-cheat diagnostics - // Rest-area zzz/blue-bar relay rows (196). Caught missing by the gc_reap_tripwire: this + // Rest-area zzz/blue-bar relay rows. Caught missing by the gc_reap_tripwire: this // table carries the same `id: u64` + `created_at: Timestamp` TTL shape as every table above but // had no reap line — every inn threshold crossing for the lifetime of a character left one more // row behind. The durable rest state (`Character.resting`/`rested_xp`) lives elsewhere; this row diff --git a/module/src/graveyard.rs b/module/src/graveyard.rs index 26e65f969..eae36ca20 100644 --- a/module/src/graveyard.rs +++ b/module/src/graveyard.rs @@ -156,7 +156,7 @@ pub(crate) fn nearest_of(candidates: &[Graveyard], px: f32, py: f32) -> Option Graveyard { diff --git a/module/src/hooks.rs b/module/src/hooks.rs index 89e838e95..2cbf75057 100644 --- a/module/src/hooks.rs +++ b/module/src/hooks.rs @@ -188,9 +188,9 @@ pub struct GossipSelectPayload { pub npc_guid: u64, /// The clicked option's POSITION in the per-player filtered menu — NOT stable across viewers /// (a cond-gated row or a second option renumbers it). Kept for the vendor/inn routing; a hook - /// that needs to identify a SPECIFIC option must use `option_row_id` instead (283). + /// that needs to identify a SPECIFIC option must use `option_row_id` instead. pub option_id: u32, - /// The clicked option's `game_gossip_option.row_id` — the STABLE identifier (283). A package + /// The clicked option's `game_gossip_option.row_id` — the STABLE identifier. A package /// that mints its own gossip option at a known reserved row_id (≥50000) keys on this, immune to /// menu position. `u32::MAX` for the trailing Farewell line and the flag-synthesized /// vendor/inn/browse options (which have no imported row). diff --git a/module/src/items/economy.rs b/module/src/items/economy.rs index dedbe460d..62aaf4fb6 100644 --- a/module/src/items/economy.rs +++ b/module/src/items/economy.rs @@ -434,7 +434,7 @@ pub(crate) fn apply_buy_item( if tmpl.buy_price == 0 { return Err("item cannot be bought".to_string()); } - // Reputation vendor discount (195): Honored+ with the vendor's parent faction cuts the BUY price + // Reputation vendor discount: Honored+ with the vendor's parent faction cuts the BUY price // (vanilla 5% per rank above Neutral — Honored 10%). Sell is unchanged. Neutral / no-standing / a // factionless vendor → 0% → full price. Applied at this single buy chokepoint, after the count math. let cost = { diff --git a/module/src/items/tables.rs b/module/src/items/tables.rs index 2520b0856..f12ded401 100644 --- a/module/src/items/tables.rs +++ b/module/src/items/tables.rs @@ -166,7 +166,7 @@ pub struct ItemTemplate { pub start_quest: u32, #[default(0)] pub bag_family: u32, - /// cmangos `BuyCount` (080): the stack a vendor hands over per purchase (water/food ×5, ammo + /// cmangos `BuyCount`: the stack a vendor hands over per purchase (water/food ×5, ammo /// ×200). Defaulted 1 so un-reimported rows keep single-unit sales. END-appended + defaulted → /// additive auto-migration. #[default(1u32)] @@ -302,7 +302,7 @@ pub struct NpcVendor { /// Per-player buyback ring — the last ≤12 items sold to any vendor. Newest = highest id; the client /// numbers slots 69–81 (SLOT1 = 0-index 0 = most recent). On sell: oldest is evicted if at capacity, /// new row inserted. On buyback: the player pays `price` copper and the item is granted back via -/// `store_item`. The gateway subscribes (coordinator) to build the vendor-window buyback tab (248). +/// `store_item`. The gateway subscribes (coordinator) to build the vendor-window buyback tab. #[table(accessor = game_character_buyback, index(accessor = by_player_guid, btree(columns = [player_guid])))] pub struct BuybackEntry { #[primary_key] diff --git a/module/src/lib.rs b/module/src/lib.rs index 646d0bef3..87013770c 100644 --- a/module/src/lib.rs +++ b/module/src/lib.rs @@ -458,7 +458,7 @@ pub use creatures::*; pub use debug::*; pub use duel::*; pub use encounter::*; -pub use exploration::CharacterExplored; // re-exported for the gateway schema-parity test (282) +pub use exploration::CharacterExplored; // re-exported for the gateway schema-parity test pub use faction::*; pub use gameobject::*; pub use gc::*; diff --git a/module/src/loot/mod.rs b/module/src/loot/mod.rs index 8480c3df7..e1118957c 100644 --- a/module/src/loot/mod.rs +++ b/module/src/loot/mod.rs @@ -57,7 +57,7 @@ pub struct CorpseLoot { // does this row carry a QUEST-only item (rolled from a `quest_only` creature/pickpocket/ // gameobject-loot row)? Quest rows now roll UNCONDITIONALLY (the drop-chance still applies; only // the pre-roll killer gate is gone) — visibility (gateway) and takability (`apply_take_loot`) are - // decided per-viewer/per-taker instead. `#[default(false)]` so every pre-187 row (and every + // decided per-viewer/per-taker instead. `#[default(false)]` so every existing row (and every // non-quest drop) keeps behaving exactly as before — additive auto-migration. #[default(false)] pub quest_only: bool, @@ -77,7 +77,7 @@ pub struct CorpseLoot { // loot-open; see the module doc below for why). All three are meaningless (stay at their // defaults) for an ungrouped kill, an FFA-method group, or any `quest_only` row (the two systems // are kept ORTHOGONAL — `apply_group_loot_rules` never touches a `quest_only` row). `#[default]` - // on all three — additive, byte-identical for every pre-187 row. + // on all three — additive, byte-identical for every existing row. /// `0` = no round-robin/master restriction (FFA, or a solo/ungrouped kill); nonzero = ONLY this /// character guid may see/take the row via the plain loot path (ROUND_ROBIN/GROUP-below- /// threshold: the corpse's picked-once designee; MASTER-above-threshold: the master looter). @@ -117,7 +117,7 @@ pub struct CreatureLoot { // END-APPENDED: cmangos `ChanceOrQuestChance < 0`, the item is a QUEST-drop, only // ever visible to a player who currently needs it (an active matching COLLECT_ITEM objective — see // `killer_needs_item`). Gated at ROLL TIME against the CREDITED KILLER (see the module doc), - // never re-evaluated later. `#[default(false)]` so existing rows (imported pre-210, or seeded) keep + // never re-evaluated later. `#[default(false)]` so existing rows keep // rolling exactly as before (never quest-gated) — additive auto-migration. #[default(false)] pub quest_only: bool, @@ -1126,7 +1126,7 @@ mod tests { } /// Schema round-trip guard: `CreatureLoot.quest_only` defaults to `false` (additive migration — - /// every pre-210 row keeps rolling unconditionally), and the new tables' row shapes construct with + /// every existing row keeps rolling unconditionally), and the new tables' row shapes construct with /// named fields exactly like `CreatureLoot` (a compile-time guard against a silently reordered / /// renamed column — the importer's positional SQL INSERT depends on this order matching its /// column list verbatim). `CorpseLoot` now END-carries `quest_only`/`reserved_for` too. @@ -1332,7 +1332,7 @@ mod tests { // ---- Money-loot split ---- /// SOLO passthrough: an empty `recipients` slice (no `game_corpse_loot_eligible` snapshot) always - /// credits the WHOLE amount to the looter in a single entry — byte-identical to the pre-221 + /// credits the WHOLE amount to the looter in a single entry — byte-identical to the /// unconditional-credit path, regardless of amount. #[test] fn split_money_solo_passthrough_credits_the_whole_amount_to_the_looter() { diff --git a/module/src/nav.rs b/module/src/nav.rs index d0a5205c1..b6f36b639 100644 --- a/module/src/nav.rs +++ b/module/src/nav.rs @@ -203,8 +203,8 @@ pub fn import_nav_chunks_append(ctx: &ReducerContext, packed: String) -> Result< // =========================================================================================== // Runtime consumption, the module-side wrappers over `lyracore_shared::nav`'s -// pure queries, gated on `game_config.nav_enabled` (default OFF; the 244 benchmark + live -// verify flips it). Every wrapper degrades to the pre-243 straight-line behavior when the +// pure queries, gated on `game_config.nav_enabled` (default OFF; the benchmark + live +// verify flips it). Every wrapper degrades to straight-line behavior when the // flag is off or a chunk is missing. `route_step` holds position when no path exists. // =========================================================================================== diff --git a/module/src/professions.rs b/module/src/professions.rs index 75ce4e8de..48ece5c03 100644 --- a/module/src/professions.rs +++ b/module/src/professions.rs @@ -167,7 +167,7 @@ pub(crate) fn skin_corpse( // `skin_loot_id` (cmangos `skinning_loot_template`, level-banded across many creatures sharing a // band); roll it and grant every winner. `skin_loot_id == 0` (unimported / a seeded/test beast) OR // an empty/no-win roll falls back to the flat `LEATHER_ENTRY`/`LEATHER_COUNT` — byte-identical to - // the pre-210 alpha, so a skin never comes up empty-handed. `?`-rollback: ANY winner's `grant_item` + // a plain flat drop, so a skin never comes up empty-handed. `?`-rollback: ANY winner's `grant_item` // failing (full bag) rolls back the WHOLE skin BEFORE the corpse is marked skinned (the reducer tx // rolls the whole call back), so no partial skin — retry after freeing space, exactly the COOKING // reagent-gate rollback shape. diff --git a/module/src/quest.rs b/module/src/quest.rs index 1646ae03c..62f4c3e3c 100644 --- a/module/src/quest.rs +++ b/module/src/quest.rs @@ -2791,7 +2791,7 @@ pub(crate) fn on_areatrigger_entered(ctx: &ReducerContext, player_guid: u64, tri /// table needs no gateway binding at all (danger-zones.md §1's "new table → regenerate bindings" rule /// only applies to tables a gateway subscription reads). [`apply_enter_areatrigger`] looks this up by /// `trigger_id`; a hit routes the player through [`crate::world::teleport_player`], a miss leaves the -/// pre-225 quest-credit-only behavior byte-identical. No Timestamp → plain SQL, clear+reload. [static] +/// quest-credit-only behavior byte-identical. No Timestamp → plain SQL, clear+reload. [static] #[table(accessor = game_areatrigger_teleport)] pub struct AreatriggerTeleport { #[primary_key] diff --git a/module/src/reputation.rs b/module/src/reputation.rs index 30d49d3bc..acbc789dd 100644 --- a/module/src/reputation.rs +++ b/module/src/reputation.rs @@ -79,7 +79,7 @@ const REP_MAX: i32 = 42000; /// Reputation RANK (vanilla `ReputationRank`, 0=Hated .. 7=Exalted, Neutral=3) for a raw standing. /// Thresholds are the raw lower bounds mangos uses. Pure — unit-tested. `pub(crate)` so vendor pricing -/// (195) can read it. Neutral is the baseline (an unknown faction → 0 standing → Neutral). +/// can read it. Neutral is the baseline (an unknown faction → 0 standing → Neutral). pub(crate) fn reputation_rank(standing: i32) -> u8 { match standing { s if s >= 42000 => 7, // Exalted @@ -93,7 +93,7 @@ pub(crate) fn reputation_rank(standing: i32) -> u8 { } } -/// Vendor buy-price discount PERCENT for a raw standing (195). Vanilla `GetReputationPriceDiscount`: +/// Vendor buy-price discount PERCENT for a raw standing. Vanilla `GetReputationPriceDiscount`: /// 5% per rank ABOVE Neutral — Friendly 5, Honored 10, Revered 15, Exalted 20; Neutral and below give 0. /// Buy only (sell is unchanged). Pure — unit-tested. pub(crate) fn reputation_discount_pct(standing: i32) -> u32 { @@ -107,7 +107,7 @@ pub(crate) fn reputation_discount_pct(standing: i32) -> u32 { } /// The vendor buy-price discount PERCENT `player_guid` gets at a creature whose FactionTemplate is -/// `faction_template_id` (195). Resolves the template → its parent Faction.dbc id → the player's standing +/// `faction_template_id`. Resolves the template → its parent Faction.dbc id → the player's standing /// with that faction → `reputation_discount_pct`. 0 when the vendor has no parent faction, the faction /// has no rep bar, or the player has no standing row (Neutral). Reuses the `grant_reputation` lookup idiom. pub(crate) fn vendor_discount_pct( diff --git a/module/src/rest.rs b/module/src/rest.rs index 6582b95ad..8aeba91cf 100644 --- a/module/src/rest.rs +++ b/module/src/rest.rs @@ -1,4 +1,4 @@ -//! Rest state (196): entering an inn/rest-area sets the RESTED flag — the client draws the zzz icon and +//! Rest state: entering an inn/rest-area sets the RESTED flag — the client draws the zzz icon and //! turns the XP bar blue (PLAYER_BYTES_2 byte 3) — accrues rested XP LIVE at the full rate while resting, //! and (via the durable `Character.resting` stamp) makes an OFFLINE logout in a rest area accrue at the //! full rate vs 1/4 in the open field. Rest-area geometry is a hand-authored fixture (the Lion's Pride @@ -157,7 +157,7 @@ pub fn debug_check_rest_at( Ok(()) } -// Live rested accrual (196): while resting ONLINE, grow the pool at the full rest rate. Lossless — +// Live rested accrual: while resting ONLINE, grow the pool at the full rest rate. Lossless — // materialize from the fixed `rested_since` clock and only advance the clock once the increment banks // ≥1 XP, so short ticks (where the sub-1-XP increment rounds to 0) don't lose accrual. Self-quantized to // ~30s: this pass fires every 0.5s `tick_creatures` tick, but rested is ~0.1 XP/min so cadence only sets diff --git a/module/src/seed.rs b/module/src/seed.rs index 97cf6fb9a..c6d32c7d8 100644 --- a/module/src/seed.rs +++ b/module/src/seed.rs @@ -225,7 +225,7 @@ fn seed_production_core(ctx: &ReducerContext) { // Action-bar rows, same no-op-pre-import grant `create_character` calls. crate::action_bar::grant_createinfo_actions(ctx, 1, Identity::ZERO, hw::RACE, hw::CLASS); - // Creature EventAI (193): the fixture on-aggro barks (Kobold/Defias/Hogger). + // Creature EventAI: the fixture on-aggro barks (Kobold/Defias/Hogger). crate::creatures::seed_on_aggro_fixtures(ctx); } diff --git a/module/src/seed/fixtures.rs b/module/src/seed/fixtures.rs index 6d05e5f19..35ed6677e 100644 --- a/module/src/seed/fixtures.rs +++ b/module/src/seed/fixtures.rs @@ -68,7 +68,7 @@ pub(crate) fn test_wolf_template() -> CreatureTemplate { damage_max: 0, armor: 0, // set via `spacetime sql` on this row to mock-test mitigation pickpocket_loot_id: 0, // not imported — the test wolf has no pickpocket table - // 0 ⇒ `skin_corpse` falls back to the flat Light Leather — the pre-210 verify flow + // 0 ⇒ `skin_corpse` falls back to the flat Light Leather — the verify flow // (debug_skin_nearest → 1x Light Leather) stays byte-identical without a seeded skin table. skin_loot_id: 0, trainer_type: 0, // the test wolf is a beast, not a trainer diff --git a/module/src/skill.rs b/module/src/skill.rs index 2594b8f16..788411e11 100644 --- a/module/src/skill.rs +++ b/module/src/skill.rs @@ -20,7 +20,7 @@ use lyracore_shared::constants::starter_item; // `threat::game_threat`), so it's in scope without a `use`. `game_item_template` (items/) is read to // resolve the equipped weapon's subclass; `game_world_entity` (world.rs) only by the debug reducer. use crate::skilldata::{game_skill_ability, game_skill_availability}; -use crate::{game_item_template, game_world_entity, WorldEntity}; // accessor trait for the autolearn read (282) +use crate::{game_item_template, game_world_entity, WorldEntity}; // accessor trait for the autolearn read // =========================================================================================== // Pure skill math + taxonomy (ctx-free, unit-tested) [server] @@ -736,7 +736,7 @@ pub(crate) fn gain_profession_skill( let Some(new_current) = raise_skill(ctx, guid, line) else { return; }; - // AUTOLEARN at threshold (282): climbing may have unlocked acquire_method=2 abilities whose + // AUTOLEARN at threshold: climbing may have unlocked acquire_method=2 abilities whose // min_skill the new rank now meets. Idempotent; owner from the live caster entity (a skill-up // only ever fires for an in-world crafter). Requires a REAL live entity — the "always // in-world" invariant broke once already, and minting a `game_player_spell` row under @@ -792,7 +792,7 @@ pub(crate) fn learn_profession( max_rank: cap, }); } - // AUTOLEARN (282): learning a profession instantly grants its base abilities — this is how + // AUTOLEARN: learning a profession instantly grants its base abilities — this is how // Charred Wolf Meat (2538) / Disenchant (13262) arrive in vanilla (NOT from a trainer). Grant // at the row's current skill; higher-threshold autolearn abilities land as it climbs (via // gain_profession_skill below). @@ -805,7 +805,7 @@ pub(crate) fn learn_profession( grant_autolearn_abilities(ctx, guid, owner, skill_line, current); } -/// AUTOLEARN grant (282): for `skill_line`, learn every `game_skill_ability` spell whose real +/// AUTOLEARN grant: for `skill_line`, learn every `game_skill_ability` spell whose real /// `acquire_method` is autolearn (1 = on skill learn, 2 = on reaching the skill rank) and whose /// `min_skill <= current`. Idempotent (`learn_spell` dedups), so it's safe to call on every learn + /// skill-up. Race/class masks on profession recipes are universal in vanilla, so they're not @@ -1096,13 +1096,13 @@ pub fn debug_learn_riding_from_trainer( /// derived from `game_skill_availability` (`SkillRaceClassInfo.dbc`'s `class_mask`, imported by /// `skilldata.rs`) joined against `skill_line::*`'s known combat lines — that data-driven replacement /// is a follow-up, not done here (this function's behavior is UNCHANGED by this item). -/// Vanilla STARTING weapon proficiencies (256) — what a FRESH character knows before visiting a +/// Vanilla STARTING weapon proficiencies — what a FRESH character knows before visiting a /// weapon master, matching vanilla's per-race/class proficiency spells (race-merged; /// wand/thrown/bow/gun lines are unmodeled — ranged rides the UNARMED fallback). The FULL per-class /// list below stays as the "ever learnable by this class" reference; seeding from THAT list gave every /// fresh paladin swords/axes/polearms and made the weapon masters pointless (live find, faladin). /// Existing characters are GRANDFATHERED: `ensure_player_skills` only inserts missing lines, never -/// deletes, so a pre-256 character keeps its wide set. +/// deletes, so an existing character keeps its wide set. pub fn class_starting_weapon_skill_lines(class: u8) -> &'static [u32] { use skill_line::*; match class { diff --git a/module/src/spell/cast/resolve.rs b/module/src/spell/cast/resolve.rs index 82ad5a116..e45a6501d 100644 --- a/module/src/spell/cast/resolve.rs +++ b/module/src/spell/cast/resolve.rs @@ -266,7 +266,7 @@ pub(crate) fn resolve_cast_at_typed( } } - // Notify-hook (146): the cast fully RESOLVED — every cast path (player, timed completion, + // Notify-hook: the cast fully RESOLVED — every cast path (player, timed completion, // channel, creature, triggered, debug) funnels through this success exit. Rejections above // returned Err and never reach here. crate::hooks::fire_on_cast_resolved( @@ -1147,7 +1147,7 @@ fn check_cast_gate_suffix( // fan-out in `select_targets` re-checks `is_hostile`/`is_friendly` PER resolved target, so a PBAoE // (explicit==0, which skips this gate) is still faction-filtered there — no exploit is opened. if target_guid != caster_guid { - // A_FLAG effects are excluded from BOTH scans (266): an inert marker aura carries whatever + // A_FLAG effects are excluded from BOTH scans: an inert marker aura carries whatever // target type the DBC happened to encode (Taunt's ModTaunt marker reads ALLY, Slice and // Dice's read ally too — the name-fix precedent) and imposes no real faction constraint; // letting it vote turned Taunt into "targets allies" and refused every yank. @@ -1194,7 +1194,7 @@ fn check_cast_gate_suffix( ))); } - // Blink (116) requires its teleport DISTANCE as data — the effect's DBC radius (`radius_yd`, + // Blink requires its teleport DISTANCE as data — the effect's DBC radius (`radius_yd`, // SpellRadius.dbc = 20yd for Blink). A 0/unauthored radius is a DATA BUG, not something to paper // over with a hardcoded default (a silent fallback would hide a mis-seeded spell forever). Reject // the cast LOUD: the Err propagates to the reducer → SMSG_CAST_FAILED at the client AND a server log. @@ -1449,7 +1449,7 @@ pub(crate) fn begin_cast_with_admission( // importer from the DBC), never a spell id. Arcane Missiles' DBC `cast_time_ms` is already 0 so it would // take the instant path regardless; this guard makes a channel resolve-now even if a channeled spell // carried a nonzero cast time, and documents the channel path explicitly. - // Spell-modifier fold (264): the caster's A_SPELLMOD auras (Improved Fireball et al) move THIS + // Spell-modifier fold: the caster's A_SPELLMOD auras (Improved Fireball et al) move THIS // spell's cast time — flat then percent, floored at 0. The folded value drives the // instant-vs-timed branch, the completion schedule AND the START event's cast bar in LOCKSTEP // (a bar that lies about the fire time is the wedge class the cast-lock saga taught us). diff --git a/module/src/spell/cast/targeting.rs b/module/src/spell/cast/targeting.rs index bb11962ab..30fe7c88b 100644 --- a/module/src/spell/cast/targeting.rs +++ b/module/src/spell/cast/targeting.rs @@ -8,7 +8,7 @@ use crate::{game_character, game_creature_template, game_world_entity}; // Tables, taxonomy consts, math helpers, and the control predicates are all re-exported by `spell::mod`. use crate::spell::*; // game_skill_ability lives in the skilldata module — its accessor trait must be in scope for the -// data-driven recipe skill-up (282). +// data-driven recipe skill-up. use crate::skilldata::game_skill_ability; // game_script (the Runtime Script identity table) is NOT re-exported at the crate root — see its // module doc — so the E_SCRIPTED arm reaches it by explicit path. @@ -31,7 +31,7 @@ pub(crate) struct EffectHit { pub resisted: u32, // magic resisted off the crit-scaled hit (0 for physical / unresisted) pub absorbed: u32, // soaked by A_ABSORB shields before the health write pub crit: bool, // the spell-crit roll landed - pub healed: u32, // EFFECTIVE health restored (overheal excluded) — feeds SMSG_SPELLHEALLOG (251) + pub healed: u32, // EFFECTIVE health restored (overheal excluded) — feeds SMSG_SPELLHEALLOG } impl EffectHit { fn none() -> Self { @@ -432,7 +432,7 @@ pub(crate) fn aura_apply( // every sibling effect sharing its spell_id, not just the A_SEAL-tagged row, or the displaced seal's // other effects (AP/haste) would linger as an orphaned buff after the swap. Generic over A_SEAL — no // spell id hardcoded here, but the OTHER seal's spell_id is looked up to scope the sibling sweep. - // `displace_auras` is the shared collect-then-delete mechanism (381) — this call site's only job is + // `displace_auras` is the shared collect-then-delete mechanism — this call site's only job is // to name WHICH spell_ids it displaces. if e.kind == A_SEAL { let other_seal_spell_ids: std::collections::HashSet = auras @@ -936,7 +936,7 @@ fn apply_damage_effect( // points` (the existing Fireball still hits for 20 on the L2 player — baseline byte-identical). let sp = spell_power(ctx, caster_guid, hdr.school_mask); let scaled = compose_magnitude(points, sp, SPELL_POWER_COEFF_PCT); - // Spell-modifier fold (264): the caster's A_SPELLMOD damage-op auras (fire-damage-% + // Spell-modifier fold: the caster's A_SPELLMOD damage-op auras (fire-damage-% // talents etc.) move THIS spell's direct damage — flat then percent, before crit // (crit amplifies the modified base, mirroring the cast-time fold's lockstep rule). let (dmg_flat, dmg_pct) = @@ -1618,7 +1618,7 @@ fn mana_burn_drain(power: u32, points: i32) -> u32 { (points.max(0) as u32).min(power) } -/// E_POWER_BURN damage math (117): `drained * ratio_bp / 100`, integer floor (vanilla damage = +/// E_POWER_BURN damage math: `drained * ratio_bp / 100`, integer floor (vanilla damage = /// drained mana * EffectMultipleValue; Mana Burn's is 0.5, carried on the effect row as `p1` = 50 /// basis-points). `ratio_bp <= 0` (unauthored/default data) is treated as 100 (1:1) so a spell that /// never got its multiplier seeded doesn't silently deal zero damage. Pure, unit-tested. @@ -1790,7 +1790,7 @@ pub(crate) fn dynamic_object_guid(area_id: u64) -> u64 { (0xF100u64 << 48) | area_id } -/// Charge (261): RUSH the caster along the ground into melee of the target — a server-driven spline (the +/// Charge: RUSH the caster along the ground into melee of the target — a server-driven spline (the /// same `SMSG_MONSTER_MOVE` leg creatures use for chase), NOT the old instant teleport-snap. Lands ~3yd /// short facing the target. The straight rush is obstruction-clamped (vanilla Charge stops short on /// blocked terrain — it does NOT path-find around, so no A*). The +rage rides a separate E_ENERGIZE @@ -2126,7 +2126,7 @@ mod effect_handler_tests { assert_eq!(super::pick_aura_slot(&both_full, 8888, false, 32, 48), None); } - /// The craft-recipe predicate is now DATA-DRIVEN (282): reagents from game_spell_reagent, the + /// The craft-recipe predicate is now DATA-DRIVEN: reagents from game_spell_reagent, the /// profession line + band from game_skill_ability — both ctx-aware, verified live in the import /// (P5) rather than unit-tested here. The one pure decision left is `is_profession_line`, which /// distinguishes a craft's skill-line ability from a combat/class spell's: a reagent-consuming diff --git a/module/src/spell/effects.rs b/module/src/spell/effects.rs index bc443d880..6cdd761e7 100644 --- a/module/src/spell/effects.rs +++ b/module/src/spell/effects.rs @@ -1,7 +1,7 @@ //! The ctx-bound spell/aura ORCHESTRATION family: the handful of `math.rs` helpers that don't just //! compute a number but WRITE to the live module — mutating `game_world_entity`/`game_aura`/ //! `game_melee_attack` rows, dispatching into `combat::apply_hit`'s kill fork, or both. Split out of -//! `math.rs` (388) so that file's own doc claim — "everything here is unit-testable without a live +//! `math.rs` so that file's own doc claim — "everything here is unit-testable without a live //! module" — is true again: every fn below takes a `ReducerContext` and has a side effect, so none of //! them belong in a "pure spell math" file, and (per the `test_scan` playbook) none of them get a direct //! unit test here either — there is no `ReducerContext` harness in this crate by design. `mod.rs` diff --git a/module/src/spell/math.rs b/module/src/spell/math.rs index 4885d2f83..091c62567 100644 --- a/module/src/spell/math.rs +++ b/module/src/spell/math.rs @@ -359,7 +359,7 @@ pub(crate) fn seal_amount(ctx: &ReducerContext, unit_guid: u64) -> i32 { sum_active_auras(ctx, unit_guid, |a| a.eff_kind == A_SEAL) } -/// `seal_amount` plus the seal's SPELL id — the swing-proc damage log (114) names its yellow combat-log +/// `seal_amount` plus the seal's SPELL id — the swing-proc damage log names its yellow combat-log /// line after the seal spell. Only one seal is ever active (`remove_seal_auras` enforces it), so the first /// `A_SEAL` row's spell_id is THE seal. `(0, 0)` with no seal (baseline-safe). pub(crate) fn seal_amount_and_spell(ctx: &ReducerContext, unit_guid: u64) -> (i32, u32) { @@ -426,7 +426,7 @@ pub(crate) fn break_stealth(ctx: &ReducerContext, unit_guid: u64) -> u32 { removed } -/// Spell-modifier fold (264): sum the CASTER's `A_SPELLMOD_FLAT` / `A_SPELLMOD_PCT` auras whose +/// Spell-modifier fold: sum the CASTER's `A_SPELLMOD_FLAT` / `A_SPELLMOD_PCT` auras whose /// SpellModOp (`eff_p0`) is `op` AND whose affected-spell family mask (`eff_p1`, the DBC /// EffectItemType) names the cast — the MODIFIER's source spell must share `family_name` with the /// cast header and `hdr.family_flags & mask != 0` (vanilla's SpellModifier match). Returns @@ -544,7 +544,7 @@ pub(crate) fn stance_allows(mask: u8, stance: u8) -> bool { /// Pure: the vanilla ShapeshiftForm id the 5875 client expects in UNIT_FIELD_BYTES_1[2] for our 0-based /// `stance` — the inverse of the importer's `form_to_stance` (see the STANCE_* convention block in /// taxonomy.rs, the definition site): Battle 0→17 / Defensive 1→18 / Berserker 2→19 / Bear 3→5 / -/// Cat 4→1 / Dire Bear 5→8. An UNASSIGNED stance id (6, 7, …) falls back to the pre-156 warrior formula +/// Cat 4→1 / Dire Bear 5→8. An UNASSIGNED stance id (6, 7, …) falls back to the warrior formula /// (`stance + 17`) so the three warrior stances' byte is BIT-IDENTICAL to what the old inline /// `(new_stance + 17)` computed and out-of-range ids behave exactly as before the widening. [pure] pub(crate) fn client_form_for_stance(stance: u8) -> u32 { diff --git a/module/src/spell/scheduler.rs b/module/src/spell/scheduler.rs index 9606a657a..1434c3d45 100644 --- a/module/src/spell/scheduler.rs +++ b/module/src/spell/scheduler.rs @@ -38,7 +38,7 @@ pub fn fire_pending_cast(ctx: &ReducerContext, sched: PendingCast) { // START(cast_time)); only a genuine instant (is_completion=false) sends the START(0)+GO pair. A 2nd // START(0) at completion reset the cast bar to zero-length ("stuck on full") — the cast-lock bug. true, - // NOT client-initiated (088): the completion GO must reach the caster via the relay — the + // NOT client-initiated: the completion GO must reach the caster via the relay — the // synchronous send only covered the instant CMSG path, never a timed completion. false, // The clicked ground point carried across the cast bar: a timed ground-AoE @@ -249,7 +249,7 @@ pub(crate) fn do_cancel_cast( ) -> Result<(), String> { interrupt_cast(ctx, caster.guid); break_channel(ctx, caster.guid); - // Un-queue a pending on-next-swing strike too (114): the vanilla client cancels a queued Heroic + // Un-queue a pending on-next-swing strike too: the vanilla client cancels a queued Heroic // Strike (pressing its lit button again) with the same CMSG_CANCEL_CAST. No refund — matches the // charge-up-front rule in the E_NEXT_SWING handler. No-op when nothing is queued. if caster.next_swing_spell != 0 { @@ -325,7 +325,7 @@ pub(crate) fn do_cancel_aura( } /// Is `expires_at` due to be reaped by NATURAL EXPIRY at `now`? Two rules, both preserved byte-for-byte -/// from the pre-232 `auras.iter().filter(|a| a.expires_at <= now && a.eff_kind != A_STEALTH)` full scan: +/// from the `auras.iter().filter(|a| a.expires_at <= now && a.eff_kind != A_STEALTH)` full scan: /// (1) the boundary is INCLUSIVE (`<=`, not `<`) — an aura expiring at EXACTLY `now` reaps this tick, the /// mirror of the periodic pass's own inclusive `>=` final-tick boundary just below (a 3s/1s DoT ticks 3×, /// not 2×, then reaps on that same tick); (2) `A_STEALTH` is a permanent-until-broken PRESENCE (vanilla diff --git a/module/src/spell/spellbook.rs b/module/src/spell/spellbook.rs index bc2477ba5..8feecc3a8 100644 --- a/module/src/spell/spellbook.rs +++ b/module/src/spell/spellbook.rs @@ -140,7 +140,7 @@ pub(crate) fn learn_spell(ctx: &ReducerContext, guid: u64, owner: Identity, spel owner_identity: owner, spell_id, }); - // Pick Lock (119): learning it grants the Lockpicking (633) skill line, so the first pick_lock cast + // Pick Lock: learning it grants the Lockpicking (633) skill line, so the first pick_lock cast // reads a real skill row (a no-op for every other spell). Runs on the single grant seam. crate::skill::grant_lockpicking_on_learn(ctx, guid, owner, spell_id); } diff --git a/module/src/spell/tables.rs b/module/src/spell/tables.rs index bc2368566..00e46d238 100644 --- a/module/src/spell/tables.rs +++ b/module/src/spell/tables.rs @@ -48,7 +48,7 @@ pub struct Spell { #[default(0)] pub stances: u8, /// SpellFamilyName (Spell.dbc `spell_class_set` — MAGE=3, WARRIOR=4, …; 0 = generic). With - /// `family_flags` this is how a spell MODIFIER names its affected spells (264): a modifier aura + /// `family_flags` this is how a spell MODIFIER names its affected spells: a modifier aura /// applies to a cast iff family_name matches AND `family_flags & modifier_mask != 0`. /// END-appended + `#[default(0)]` → auto-migrates. [data] #[default(0)] @@ -394,13 +394,13 @@ pub struct SpellCastEvent { // delay_ms note above). END-appended + #[default(0u32)] → additive auto-migration. #[default(0u32)] pub healed: u32, - // Legacy compatibility field on a PROC_LOG row (114), such as Seal of Righteousness damage on a + // Legacy compatibility field on a PROC_LOG row, such as Seal of Righteousness damage on a // landed melee swing. The Gateway sends only SMSG_SPELLNONMELEEDAMAGELOG. An on-next-swing fire // uses GO with is_completion=true because the client holds a pending cast for it. // END-appended + #[default(false)] -> additive auto-migration. #[default(false)] pub is_proc_log: bool, - // The melee swing outcome an on-next-swing FIRE row rode (114): CombatEvent hit_info codes + // The melee swing outcome an on-next-swing FIRE row rode: CombatEvent hit_info codes // (0 normal, 1 crit, 2 miss, 3 dodge, 4 parry, ...). The relay shapes the SMSG_SPELL_GO miss // list from it when damage == 0 — the client then prints the yellow "Your Heroic Strike // missed/was dodged/was parried" line instead of a white MISS. 0 (normal) on every other row. diff --git a/module/src/spell/taxonomy.rs b/module/src/spell/taxonomy.rs index 8819bf5b4..0382d072e 100644 --- a/module/src/spell/taxonomy.rs +++ b/module/src/spell/taxonomy.rs @@ -96,7 +96,7 @@ pub(crate) const E_PICKPOCKET: u8 = 0x10; // grant the rogue a small copper pock pub(crate) const E_INTERRUPT: u8 = 0x11; // cancel the target's in-progress (timed) cast (Kick) — calls interrupt_cast; no-op if not casting. Kick ALSO locks the cast's SCHOOL for ~5s (game_school_lockout, Rogue Slice 3) pub(crate) const E_REDUCE_THREAT: u8 = 0x12; // one-time reduction of the CASTER's CURRENT threat by base_points across all its source rows (Feint), floored at 0 — DISTINCT from Fade's A_MOD_COMBAT(COMBAT_THREAT) FUTURE-threat percent pub(crate) const E_NEXT_SWING: u8 = 0x13; // QUEUE the strike onto the caster's next melee swing (Heroic Strike/Cleave): the cast sets next_swing_spell + charges rage (no instant damage); the next LANDED swing (resolve_swing) adds this effect's base_points as bonus damage and clears the field -pub(crate) const E_SET_STANCE: u8 = 0x14; // set the caster's stance/form to p0, per the STANCE_* convention block below (THE definition site): Warrior 0/1/2, Druid Bear/Cat/DireBear 3/4/5 (156) — a one-shot state write to WorldEntity.stance, mutually exclusive (the old stance is overwritten, no aura cleanup). The importer reclassifies the stance/form spells' inert ModShapeshift→A_FLAG marker to this kind BY NAME, with p0 remapped from the vanilla form id via form_to_stance (the lockstep inverse of client_form_for_stance — NOT a fixed −17 offset since druid forms landed). Recasting the ACTIVE druid form toggles back to 0 (form_recast_toggles_off); a warrior same-stance recast stays a no-op. Switching automatically clears the prior stance's mitigation/threat because those are FOLDS keyed on the field, not auras +pub(crate) const E_SET_STANCE: u8 = 0x14; // set the caster's stance/form to p0, per the STANCE_* convention block below (THE definition site): Warrior 0/1/2, Druid Bear/Cat/DireBear 3/4/5 — a one-shot state write to WorldEntity.stance, mutually exclusive (the old stance is overwritten, no aura cleanup). The importer reclassifies the stance/form spells' inert ModShapeshift→A_FLAG marker to this kind BY NAME, with p0 remapped from the vanilla form id via form_to_stance (the lockstep inverse of client_form_for_stance — NOT a fixed −17 offset since druid forms landed). Recasting the ACTIVE druid form toggles back to 0 (form_recast_toggles_off); a warrior same-stance recast stays a no-op. Switching automatically clears the prior stance's mitigation/threat because those are FOLDS keyed on the field, not auras pub(crate) const E_SUMMON_PET: u8 = 0x15; // summon a persistent PET creature owned by the caster (Summon Imp): despawn any existing pet, then spawn the creature `p0` (p0_kind = P_ENTRY, a game_creature_template.entry) at the caster with owner_guid = caster. ONE pet per owner (re-summon replaces). The pet is a normal creature entity (no spawn row) that rides the behavior cycle's pet phase (follow the owner when idle / engage the owner's target in combat) + the existing chase/melee. The importer maps the raw vanilla Summon effect (56) to this kind with the misc_value (the summoned creature entry) routed into p0. Despawned on owner logout/death pub(crate) const E_HEAL_MAX_HEALTH: u8 = 0x16; // heal the target to FULL max health (Lay on Hands): the vanilla HealMaxHealth effect (67) heals to max regardless of base_points (its DBC base is ~0), so it canNOT route through E_HEAL (which heals `points`, i.e. 0). Sets t.health = t.max_health and credits the full heal as threat. The importer splits effect 67 out of E_HEAL onto this kind (E_HEAL stays effect 10 only). Spell-power-agnostic (a max-heal needs no scaling) pub(crate) const E_TAME_CREATURE: u8 = 0x20; // completed Hunter tame: convert the explicit wild target into one durable Hunter identity plus a live Hunter pet @@ -104,8 +104,8 @@ pub(crate) const E_FEED_PET: u8 = 0x21; // consume an explicit item target after pub(crate) const E_ENCHANT_ITEM: u8 = 0x17; // apply a permanent enchant to the cast's ITEM target: p0 = the enchant_id (p0_kind = P_ENCHANT_ID), looked up in the imported enchantment catalogue for the stat bonus. The spell never runs through resolve_cast (it targets an item GUID, not a unit) — the GATEWAY intercepts CMSG_CAST_SPELL, sees this effect kind, resolves the item GUID→bag slot, and calls enchant_item_on_slot(slot, p0). Routing is by KIND + p0, NOT a hardcoded spell-id list, so a new enchant is a data row pub(crate) const E_DISENCHANT: u8 = 0x18; // disenchant the cast's ITEM target into reagents: same gateway-intercept path as E_ENCHANT_ITEM, dispatched to disenchant_item(slot). No params (the module validates disenchantability + yields dust by item) pub(crate) const E_PERSISTENT_AREA: u8 = 0x1B; // GROUND-AoE (Consecration/Blizzard/Rain of Fire/Flamestrike-patch): an INSTANT effect that, on cast, spawns a fixed-position `game_ground_area` row at the anchor (caster pos for a self/target-0 area; the clicked ground dest once dest-coords are plumbed). Its own scheduled `tick_ground_areas` re-scans the radius each `period_ms` and applies `amount` `school` damage to every hostile inside via the shared apply_resistance→apply_target_damage path (threat/kill/absorb/break-CC reused), reaping the row at `expires_at`. DIRECT per-tick damage (no per-unit aura churn) — the clean-slate choice over mangos' DynamicObject+SpellAuraHolder-add/remove, equivalent for pure-damage areas. The importer reclassifies the ground A_PERIODIC_DAMAGE effect to this kind BY NAME. A future non-damage ground field (heal/slow) extends the emitted descriptor -pub(crate) const E_FISH: u8 = 0x1C; // Fishing (060): gateway-intercepted like E_ENCHANT_ITEM/E_DISENCHANT — CMSG_CAST_SPELL for a spell carrying this kind routes to the `fish` reducer (instant-resolve alpha catch; the bobber/channel flow is the deferred follow-up). Inert in-module (no resolve arm); exists so the gateway routes by DATA, never a spell-id list. -pub(crate) const E_OPEN_LOCK: u8 = 0x1D; // Pick Lock (119): gateway-intercepted like E_FISH (0x1C) — CMSG_CAST_SPELL for a spell carrying this kind routes to the `pick_lock` reducer (unlock a locked GameObject, gated on the caster's Lockpicking 633 skill vs the game_lock required_skill). Inert in-module (NO resolve arm in cast.rs); exists so the gateway routes by DATA, never a spell-id list. 0x1E is reserved for a future E_SUMMON_PORTAL — do NOT reuse. +pub(crate) const E_FISH: u8 = 0x1C; // Fishing: gateway-intercepted like E_ENCHANT_ITEM/E_DISENCHANT — CMSG_CAST_SPELL for a spell carrying this kind routes to the `fish` reducer (instant-resolve alpha catch; the bobber/channel flow is the deferred follow-up). Inert in-module (no resolve arm); exists so the gateway routes by DATA, never a spell-id list. +pub(crate) const E_OPEN_LOCK: u8 = 0x1D; // Pick Lock: gateway-intercepted like E_FISH (0x1C) — CMSG_CAST_SPELL for a spell carrying this kind routes to the `pick_lock` reducer (unlock a locked GameObject, gated on the caster's Lockpicking 633 skill vs the game_lock required_skill). Inert in-module (NO resolve arm in cast.rs); exists so the gateway routes by DATA, never a spell-id list. 0x1E is reserved for a future E_SUMMON_PORTAL — do NOT reuse. pub(crate) const E_BLINK: u8 = 0x1A; // teleport the caster ~20yd FORWARD along its facing (Mage Blink, 116): a self-cast position change reusing the teleport core (like E_CHARGE), clamped to the furthest nav-LoS-clear point so it doesn't cross geometry. Root/snare removal rides a separate A_IMMUNITY effect. The importer name-rescues the dead SCRIPT teleport effect (raw 29) to this kind pub(crate) const E_RECALL_HOME: u8 = 0x1F; // teleport the caster to its bound HOME (Hearthstone): a self-cast recall reusing the shared `world::recall_to_home` core, always to instance 0 regardless of the caster's current instance. Data-driven — a consumable's `spellid_1` naming a spell that carries this kind IS "a recall item"; `items::ops::apply_item_use` reads that (not a hardcoded item entry) to skip the normal stack-consumption a used-up consumable takes, since a recall trinket is never consumed. No cost/cooldown gate yet (the vanilla ~10s cast + 1hr CD is the same later follow-up E_BLINK's forward-teleport already defers) pub(crate) const E_DUEL: u8 = 0x22; // Duel (raw effect 83): request a server-authoritative Duel; p0 is the duel-flag gameobject template entry diff --git a/module/src/spell/tests.rs b/module/src/spell/tests.rs index a749a73cf..0e2ab011d 100644 --- a/module/src/spell/tests.rs +++ b/module/src/spell/tests.rs @@ -89,7 +89,7 @@ fn stance_allows_gate() { /// The stance → client ShapeshiftForm byte map (UNIT_FIELD_BYTES_1[2], written by the E_SET_STANCE arm): /// the inverse of the importer's form_to_stance per the taxonomy STANCE_* convention block. The warrior -/// trio MUST stay byte-identical to the pre-156 inline `stance + 17` (pinned against hardcoded values, +/// trio MUST stay byte-identical to the inline `stance + 17` (pinned against hardcoded values, /// not the formula); unassigned ids keep the legacy fallback. #[test] fn client_form_for_stance_matches_the_convention() { @@ -99,7 +99,7 @@ fn client_form_for_stance_matches_the_convention() { assert_eq!(client_form_for_stance(STANCE_BEAR), 5); assert_eq!(client_form_for_stance(STANCE_CAT), 1); assert_eq!(client_form_for_stance(STANCE_DIRE_BEAR), 8); - // Unassigned stance ids (6, 7) fall back to the legacy warrior formula — the pre-156 behavior. + // Unassigned stance ids (6, 7) fall back to the legacy warrior formula. assert_eq!(client_form_for_stance(6), 23); assert_eq!(client_form_for_stance(7), 24); } diff --git a/module/src/stats.rs b/module/src/stats.rs index 8e7acda08..20636fbfd 100644 --- a/module/src/stats.rs +++ b/module/src/stats.rs @@ -237,7 +237,7 @@ pub(crate) fn set_character_level( // …and the THRESHOLD, which the xp reset alone does not fix. `grant_xp`'s ding loop compares // `xp >= next_level_xp`, so a character left holding its OLD level's (much smaller) threshold // dings on its very next kill: a bot set to level 10 hit 11 after one wolf, in every bot test - // that stages a level. Same defect family as the xp reset above (266) — that fixed the banked + // that stages a level. Same defect family as the xp reset above — that fixed the banked // side and left the bar where it was. e.next_level_xp = crate::xp::xp_to_next_level(level); apply_level_stats(ctx, &mut e, race, class, level); // attributes + armor + max health/power @@ -250,7 +250,7 @@ pub(crate) fn set_character_level( crate::spell::recompute_sheet(ctx, character_guid); } - // Persist the level to the character row so a relog keeps it. XP resets to 0 (266): banked + // Persist the level to the character row so a relog keeps it. XP resets to 0: banked // xp survives a level-set otherwise, and any pool past the new level's threshold re-dings // the character on the next xp pass — "set level 5" on a played character silently became // level 6+, skewing every level-keyed fixture (exploration's L5 discovery paid the L6 value). diff --git a/module/src/world.rs b/module/src/world.rs index 558804bf5..40e1befc1 100644 --- a/module/src/world.rs +++ b/module/src/world.rs @@ -260,8 +260,8 @@ pub struct WorldEntity { #[default(0u64)] pub revenge_until_ms: u64, // The unit's active stance/form, 0-based per the taxonomy STANCE_* convention block (THE definition - // site): Warrior Battle 0 (default) / Defensive 1 / Berserker 2, Druid Bear 3 / Cat 4 / DireBear 5 - // (156). Written by the E_SET_STANCE effect (stance/form spells, importer name-rescued). The cast + // site): Warrior Battle 0 (default) / Defensive 1 / Berserker 2, Druid Bear 3 / Cat 4 / DireBear 5. + // Written by the E_SET_STANCE effect (stance/form spells, importer name-rescued). The cast // gate reads it (Spell.dbc Stances usability mask → `stance_allows`); the combat folds key the // Defensive mitigation/threat off it directly (a pure function of this field, so a switch clears // the old stance's effect for FREE — no aura cleanup). 0 for classes with no stance mechanic AND @@ -324,7 +324,7 @@ pub struct WorldEntity { #[default(false)] pub godmode: bool, - /// Rest state (196): the LIVE resting flag, so `check_rest_state` can detect an inn threshold + /// Rest state: the LIVE resting flag, so `check_rest_state` can detect an inn threshold /// crossing with an in-memory compare against the already-loaded mover row — no per-heartbeat /// `game_character` lookup (only a threshold FLIP touches the DB). Restored from `Character.resting` /// at spawn; persisted back at logout. `#[default(false)]` + END-appended → `publish` auto-migrates. @@ -523,7 +523,7 @@ pub struct EntityMotion { // ── Anti-cheat: movement plausibility (255, tier 1 — DETECT-AND-FLAG, never reject inline) ──────────── // The mangos-anticheat lesson: rubber-banding a false positive is worse than the cheat. We LOG anomalies -// and leave the position write untouched; a GM tool (205) surfaces the flags. Per-character score = the +// and leave the position write untouched; a GM tool surfaces the flags. Per-character score = the // COUNT of a guid's rows (no separate counter table — a detect-and-flag MVP doesn't need O(1) reads). pub const MOVE_VIOLATION_SPEED: u8 = 1; // observed speed over the client's own elapsed time > allowed pub const MOVE_VIOLATION_TELEPORT: u8 = 2; // a single heartbeat delta larger than any legit step @@ -631,8 +631,8 @@ pub(crate) fn score_and_log_movement(ctx: &ReducerContext, guid: u64, delta: &Mo }); } -/// A logged movement-plausibility anomaly (255). One row per flagged delta; the flag is advisory (the -/// move was NOT rejected). Not `public` — server-internal until the GM console (205) surfaces it. These +/// A logged movement-plausibility anomaly. One row per flagged delta; the flag is advisory (the +/// move was NOT rejected). Not `public` — server-internal until the GM console surfaces it. These /// are recent diagnostics, reaped after the shared `EVENT_TTL_MICROS` window so a benchmark's intentional /// speeders cannot grow the table without bound. Query a live character with /// `SELECT * FROM game_movement_violation WHERE guid = :guid`; durable forensics belongs in reducer logs. @@ -1138,7 +1138,7 @@ pub(crate) fn apply_player_login( if consumed { character.last_logout_micros = 0; } - // Rest state (196): resume the LIVE accrual clock if this character logs back in still flagged + // Rest state: resume the LIVE accrual clock if this character logs back in still flagged // resting (logged out in an inn). The first movement heartbeat re-detects and clears it if they've // since walked out. The spawn baked the RESTED byte into PLAYER_BYTES_2 from `character.resting`. if character.resting { @@ -1448,9 +1448,9 @@ pub(crate) fn apply_movement_update( // pure-turn / stationary heartbeat (which does not) — vanilla breaks a channel on movement, not on // turning in place. Compared below, after the row is persisted. let (old_x, old_y, old_z) = (mover.x, mover.y, mover.z); - // Exploration (200): the pre-move grid cell, to gate the area check to a real cell crossing below. + // Exploration: the pre-move grid cell, to gate the area check to a real cell crossing below. let (old_gx, old_gy) = (mover.grid_x, mover.grid_y); - // Anti-cheat (255): the mover's prior heartbeat time, for the speed check's dt. Read here before the + // Anti-cheat: the mover's prior heartbeat time, for the speed check's dt. Read here before the // row overwrites last_move_ms below. The player/godmode/GM exemptions live in score_and_log_movement. let old_move_ms = mover.last_move_ms; // SNAPSHOT PERSISTENCE (perf catalog 2.2): the fields whose change forces the row write below. @@ -1482,7 +1482,7 @@ pub(crate) fn apply_movement_update( let mover_guid = mover.guid; let map_id = mover.map_id; let instance_id = mover.instance_id; - // FALL DAMAGE (058): a landing packet carries the client's airborne time — fold it through the + // FALL DAMAGE: a landing packet carries the client's airborne time — fold it through the // shared curve (lyracore_shared::env, the SAME one the gateway's flavor-log line uses). The // health/lethal DECISION is `resolve_fall_damage` (pure, tested); this block only computes the // curve's raw damage figure and applies the decision. Godmode and already-dead movers skip. @@ -1500,7 +1500,7 @@ pub(crate) fn apply_movement_update( } } // AREA CROSSING: on crossing into a new grid cell, resolve the position's area ONCE and drive - // both hooks off it — discovery XP (200) if this is a fresh subzone, and the authoritative zone + // both hooks off it — discovery XP if this is a fresh subzone, and the authoritative zone // transition the Gateway routes zone-scoped delivery on. Gated on the grid change so the lookup // runs ~once per 50 yd, not per heartbeat; a grid change also forces the single `update` below, // so both hooks' mutations to `mover` persist without a second write. Unimported terrain or area @@ -1511,7 +1511,7 @@ pub(crate) fn apply_movement_update( apply_zone_transition(ctx, &mut mover, Some(crate::terrain::zone_of(&area))); } } - // Rest state (196): not grid-gated (an inn is smaller than a 50yd cell) but THROTTLED to + // Rest state: not grid-gated (an inn is smaller than a 50yd cell) but THROTTLED to // ~1Hz per mover: gate on the heartbeat clock crossing a second boundary. At run // speed that is a check every ~7yd — still finer than any inn — and it removes a per- // heartbeat rest evaluation from the hottest path on the server (measured: the per-move @@ -1594,7 +1594,7 @@ pub(crate) fn apply_movement_update( crate::spell::break_channel(ctx, mover_guid); } - // Anti-cheat (255): score this delta for speed / teleport plausibility and LOG (never reject) any + // Anti-cheat: score this delta for speed / teleport plausibility and LOG (never reject) any // anomaly (exemptions — player-only, godmode, GM — are enforced inside the helper). Server-side // teleport/blink/charge write the stored position first, so the following client delta is small and // auto-exempt (no special-case). @@ -1828,7 +1828,7 @@ pub(crate) fn do_repop(ctx: &ReducerContext, guid: u64) -> Result<(), String> { /// its persist, so a preserved ghost there would have nothing to reclaim). A dead-but-UNRELEASED /// player (death screen, no ghost flag yet) deliberately does NOT preserve: the only cross-map paths /// a dead-unreleased player can take are GM/debug teleports (movement is frozen pre-release), and -/// rebuilding those alive-at-1-HP matches the pre-226 behavior for that GM edge. Pure. [226] +/// rebuilding those alive-at-1-HP is intended for that GM edge. Pure. pub(crate) fn persisted_pending_ghost(dead: bool, player_flags: u32, set_offline: bool) -> bool { !set_offline && dead && player_flags & lyracore_shared::constants::player_flags::GHOST != 0 } @@ -2049,7 +2049,7 @@ pub(crate) fn persist_entity(ctx: &ReducerContext, entity: &WorldEntity, set_off // offline span. Only on a REAL logout/disconnect (set_offline) — the ghost-relog cleanup // path passes false, so it never falsely starts the rest clock. c.last_logout_micros = ctx.timestamp.to_micros_since_unix_epoch() as u64; - // Rest state (196): persist the live resting flag (so `player_login` picks the full vs 1/4 + // Rest state: persist the live resting flag (so `player_login` picks the full vs 1/4 // offline rate) and BANK any live accrual before the offline clock takes over from the // logout stamp — otherwise the online stay's rested would be lost. c.resting = entity.resting; @@ -2425,7 +2425,7 @@ mod tests { "alive + stale GHOST flag is not a ghost" ); // Dead but UNRELEASED (death screen, no ghost flag): deliberately NOT preserved — see the - // fn's doc comment (GM-teleport edge keeps the pre-226 alive-at-1-HP rebuild). + // fn's doc comment (GM-teleport edge keeps the alive-at-1-HP rebuild). assert!( !persisted_pending_ghost(true, 0, false), "dead-unreleased does not preserve" diff --git a/module/src/xp.rs b/module/src/xp.rs index 841bbd47e..bafe6c13e 100644 --- a/module/src/xp.rs +++ b/module/src/xp.rs @@ -274,7 +274,7 @@ fn rest_pool_after(pool: u32, offline_micros: u64, level: u32) -> u32 { (pool as u64 + accrued).min(cap) as u32 } -/// Live rested accrual (196): the pool after resting ONLINE for `elapsed_micros` at the FULL rest rate, +/// Live rested accrual: the pool after resting ONLINE for `elapsed_micros` at the FULL rest rate, /// starting from `pool`. Same math as the offline path (`rest_pool_after`) — a rest-area inn accrues at /// the full rate whether you're logged in or out. Lossless when driven off a fixed `rested_since` clock: /// the caller advances the clock only once the increment banks ≥1 XP, so sub-1-XP ticks don't round away. @@ -285,7 +285,7 @@ pub(crate) fn rest_accrue_live(pool: u32, elapsed_micros: u64, level: u32) -> u3 /// Accrue rested XP onto a character for being offline since `last_logout_micros` — called by /// `player_login`. Returns `(new_rested_pool, consume_logout)`: the grown pool, and `true` once the /// logout stamp has been consumed (so a re-login without an intervening logout can't double-accrue). A -/// never-logged-out character (`last_logout_micros == 0`) accrues nothing. `was_resting` (196) = logged +/// never-logged-out character (`last_logout_micros == 0`) accrues nothing. `was_resting` = logged /// out in a rest area (inn/city) → the FULL rate; the open field accrues at 1/4 (vanilla). The rate /// scales linearly with offline time before the 1.5-level cap, so quartering the effective offline span /// is exactly the 1/4 field rate. [entity] From 67d87c7c4d1bdec1d0cc5cee461238c056a25f03 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Sat, 10 Oct 2026 16:04:00 +0200 Subject: [PATCH 21/21] docs(comments): drop remaining work-item tags from Rust comments Remove [NNN] tags, 'the NNN contract/gate', 'NNN's' references and parenthesised work-item numbers. Keep spell, skill line and DBC ids. No code change. Co-Authored-By: Claude Sonnet 5.5 --- crates/lyracore-shared/src/env.rs | 2 +- crates/lyracore-shared/src/nav.rs | 6 +++--- gateway/src/codec/gameobject.rs | 2 +- gateway/src/codec/tests.rs | 2 +- importer/src/dbc.rs | 6 +++--- importer/src/main.rs | 22 +++++++++++----------- importer/src/spell.rs | 10 +++++----- module/build.rs | 2 +- module/src/bridge.rs | 2 +- module/src/combat/death.rs | 4 ++-- module/src/combat/engage.rs | 2 +- module/src/combat/swing.rs | 16 ++++++++-------- module/src/creatures/ai.rs | 4 ++-- module/src/creatures/spawn.rs | 2 +- module/src/creatures/tick/lifecycle.rs | 2 +- module/src/debug/encounter.rs | 2 +- module/src/debug/mod.rs | 4 ++-- module/src/gc.rs | 2 +- module/src/group.rs | 2 +- module/src/instance.rs | 12 ++++++------ module/src/items/tables.rs | 2 +- module/src/loot/rolls.rs | 2 +- module/src/quest.rs | 2 +- module/src/spell/cast/mod.rs | 2 +- module/src/spell/cast/resolve.rs | 6 +++--- module/src/spell/cast/targeting.rs | 4 ++-- module/src/spell/control.rs | 4 ++-- module/src/spell/taxonomy.rs | 4 ++-- module/src/talent.rs | 10 +++++----- module/src/trainer.rs | 2 +- module/src/world.rs | 10 +++++----- 31 files changed, 77 insertions(+), 77 deletions(-) diff --git a/crates/lyracore-shared/src/env.rs b/crates/lyracore-shared/src/env.rs index e4362eccd..2afcc03e5 100644 --- a/crates/lyracore-shared/src/env.rs +++ b/crates/lyracore-shared/src/env.rs @@ -77,7 +77,7 @@ pub fn drowning_damage(max_health: u32) -> u32 { /// folded into the mangos height curve `damage% = 1.8%*yd - 24.26%` (safe under ~13.5 yd, which is /// ~1.18 s airborne). `fall_time_ms` is the client's MovementInfo fall time on MSG_MOVE_FALL_LAND — /// it INCLUDES the jump ascent, so a flat-ground hop (~750 ms) lands at 0. Client-authoritative like -/// all movement (a hacked client can lie; the 255 anti-cheat item owns that). Capped at max_health. +/// all movement (a hacked client can lie; the movement anti-cheat owns that). Capped at max_health. pub fn fall_damage(fall_time_ms: u32, max_health: u32) -> u32 { let t = fall_time_ms as f32 / 1000.0; let height_yd = 0.5 * 19.29 * t * t; diff --git a/crates/lyracore-shared/src/nav.rs b/crates/lyracore-shared/src/nav.rs index eb850fb25..0ded592ca 100644 --- a/crates/lyracore-shared/src/nav.rs +++ b/crates/lyracore-shared/src/nav.rs @@ -109,7 +109,7 @@ mod tests { // ============================================================================================= // Runtime queries, pure algorithms over fetched chunks. The chunk source is // a closure (module: one PK find per cell; tests: synthetic chunks) so ALL pathing/LoS logic -// unit-tests here without a database. Missing chunk = "no obstacles known" (the 241 contract: +// unit-tests here without a database. Missing chunk = "no obstacles known" (the sparse-chunk contract: // fully-clear cells emit no row), so every query degrades to today's straight-line behavior // off-slice. // ============================================================================================= @@ -356,7 +356,7 @@ pub fn find_leg( find_leg_ex(fetch, from, to, max_expansions).map(|(path, _, _)| path) } -/// `find_leg` + the expansion count + completeness (exposed for tests and the 244 benchmark). +/// `find_leg` + the expansion count + completeness (exposed for tests and the benchmark). /// When the expansion budget exhausts before reaching the goal, returns the BEST-EFFORT path — /// to the explored node nearest the goal (complete=false) — so a per-tick chase leg still makes /// real progress around large obstacles and re-plans next tick, instead of falling back to a @@ -665,7 +665,7 @@ mod runtime_tests { /// One synthetic chunk at the Northshire cell: a full-height wall along nx=32 (obs ox=16) /// with a 4-sub-cell doorway at ny 30..34 (obs oy 15..17), plus a sealed 4-wall pocket in /// the corner (nx 4..12, ny 4..12 ring). Every other cell in the world is "missing" (all - /// clear) — exactly the 241 skip-all-clear contract. + /// clear) — exactly the skip-all-clear contract. fn walled_cell() -> ((u16, u16), NavCellData) { let (cx, cy) = (cell_index(-8913.0).unwrap(), cell_index(-184.0).unwrap()); let mut walk = vec![0xFFu8; WALK_BYTES]; diff --git a/gateway/src/codec/gameobject.rs b/gateway/src/codec/gameobject.rs index 68efef584..8ffcff850 100644 --- a/gateway/src/codec/gameobject.rs +++ b/gateway/src/codec/gameobject.rs @@ -145,7 +145,7 @@ pub fn build_gameobject_query_response( } } -/// A ground-area spell's DYNAMICOBJECT CREATE (118, Consecration's swirl): the 5875 client renders +/// A ground-area spell's DYNAMICOBJECT CREATE (Consecration's swirl): the 5875 client renders /// the persistent ground effect from `DYNAMICOBJECT_SPELLID`'s SpellVisual, sized by RADIUS at /// POS — it never draws it from the cast packets alone (live find). Same stationary /// `CreateObject2` + `HasPosition` shape as the gameobject CREATE above; entry = the spell id and diff --git a/gateway/src/codec/tests.rs b/gateway/src/codec/tests.rs index 427746d2c..0ba0c532c 100644 --- a/gateway/src/codec/tests.rs +++ b/gateway/src/codec/tests.rs @@ -2244,7 +2244,7 @@ fn cast_result_failed_body_and_reason_map() { #[test] fn spell_damage_log_renders_crit_and_breakdown() { // A crit cast → hit_info encodes 0x2 (SPELL_HIT_TYPE_CRIT — gtker's AffectsVictim variant; the - // MELEE-named CriticalHit encodes 0x80, which the client ignores in THIS packet — the 114 live + // MELEE-named CriticalHit encodes 0x80, which the client ignores in THIS packet — the live // find: a 22-damage Heroic Strike crit rendered plain). Breakdown fields propagate unchanged. let crit = build_spell_non_melee_damage_log(0x42, 0x7, 133, 30, 4, true, 5, 3); assert_eq!(crit.hit_info, HitInfo::AffectsVictim); diff --git a/importer/src/dbc.rs b/importer/src/dbc.rs index b2dd3a5f7..4e04bb3bb 100644 --- a/importer/src/dbc.rs +++ b/importer/src/dbc.rs @@ -261,7 +261,7 @@ pub fn run(data_dir: &str, args: &Args) -> Result<()> { let autolearn_count = skill_abilities .rows() .iter() - // real AcquireMethod lives in the mis-named `trivial_skill_line_rank_high` (282, see skill_ability_sql). + // real AcquireMethod lives in the mis-named `trivial_skill_line_rank_high` (see skill_ability_sql). .filter(|r| r.trivial_skill_line_rank_high == AUTOLEARN_ACQUIRE_METHOD) .count(); let (sl_stmts, sl_count) = skill_line_sql(&skill_lines); @@ -783,7 +783,7 @@ fn faction_template_sql(chain: &mut PatchChain) -> Result<(Vec, usize)> /// Clear+reload SQL for `game_area` from `AreaTable.dbc`: every zone AND subzone row /// (id, map, parent, area_bit, flags, exploration level, faction group, name). `flags` is the raw /// `AreaFlags` bitmask reinterpreted as `u32` (`as_int()` widened) — undecoded here, a consumer -/// decodes what it needs (rest-state city detection is 196's job). Name via `.en_gb` + `sql_text` +/// decodes what it needs (rest-state city detection is left to the consumer). Name via `.en_gb` + `sql_text` /// (apostrophe-escaping), matching `skill_line_sql`'s convention. Loads ALL rows (small table — map /// filtering is a verify-time concern, not an import-time one). No Timestamp → plain SQL. fn area_sql(table: &DbcAreaTable) -> (Vec, usize) { @@ -2253,7 +2253,7 @@ mod tests { } // Params are REAL SkillLineAbility semantics; they're placed into the wow_dbc fields the - // corrected `skill_ability_sql` reads for each (see the off-by-one remap there, 282): real + // corrected `skill_ability_sql` reads for each (see the off-by-one remap there): real // min_skill → superseded_by, real acquire_method → trivial_high, real gray → trivial_low, // real green → character_points[0]. #[allow(clippy::too_many_arguments)] diff --git a/importer/src/main.rs b/importer/src/main.rs index bd69e2eff..db11ef8c9 100644 --- a/importer/src/main.rs +++ b/importer/src/main.rs @@ -992,8 +992,8 @@ pub(crate) struct Args { dump: Option, // cmangos creature ETL input (.sql[.gz]) dbc: Option, // client Data/ dir for the DBC stream pub(crate) terrain: Option, // client Data/ dir for the ADT heightmap stream (see terrain.rs) - pub(crate) dump_collision: Option, // client Data/ dir: 240 spike, WMO/M2 collision dry-run (see collision.rs) - pub(crate) nav: Option, // client Data/ dir: 241 nav-grid rasterizer (see nav.rs) + pub(crate) dump_collision: Option, // client Data/ dir: spike, WMO/M2 collision dry-run (see collision.rs) + pub(crate) nav: Option, // client Data/ dir: nav-grid rasterizer (see nav.rs) pub(crate) vmap: Option, // Client geometry input for the per-cell collision catalogue. pub(crate) vmap_status: bool, // print active-generation provenance/status without opening client data pub(crate) vmap_prepare_coverage: Option, // generation id: derive path-grid coverage from an already-staged generation (see vmap.rs::run_coverage) @@ -1741,7 +1741,7 @@ fn distinct_race_class_combos(rows: &[(u32, u32)]) -> usize { /// the hand-authored `CREATEINFO_KIT` rows if the table is EMPTY (the `game_start_position` / Talent.dbc /// precedent — an importer clear+reload REPLACES whatever is there, seed or a prior import; it /// does not merge or coexist with it). The `id >= 0` DELETE is a tautology on the unsigned PK (mirrors -/// 207's own phrasing) — it's a wholesale wipe, not a conditional one. A character created BEFORE this +/// the sibling loaders) — it's a wholesale wipe, not a conditional one. A character created BEFORE this /// import keeps the `game_player_spell` rows ITS creation already copied (durable, per-character); only /// characters created AFTER the import see the real dump-sourced kit. `race`/`class` are NOT /// re-validated as wildcards here — a dump row's own 0 (if any) passes through verbatim and @@ -2760,7 +2760,7 @@ fn creature_drop_item_set( /// Items lootable from IN-SLICE gameobjects (chests): `gameobject_template` type 3 rows among /// `used_go` carry their `gameobject_loot_template` lootId in DATA1; union those pools' items. -/// Extends the 042 obtainability gate — the grape/crate/wood collect quests want items that only +/// Extends the obtainability gate — the grape/crate/wood collect quests want items that only /// GO loot drops, and skipping their objectives made the quests auto-complete for a free reward. /// Same `parse_loot_family` the GO-loot builder uses, so the two never disagree. fn gameobject_loot_item_set( @@ -2788,7 +2788,7 @@ fn gameobject_loot_item_set( } /// Items sold by IN-SLICE vendors (direct `npc_vendor` rows + the `npc_vendor_template` -/// indirection via creature_template col 76) — the third leg of the 042 obtainability gate +/// indirection via creature_template col 76) — the third leg of the obtainability gate /// (q3861's kodo feed is vendor-only). A trimmed twin of the canonical vendor ETL in /// `build_items_and_loot` (which also carries slots/maxcount); only the ITEM SET matters here. fn vendor_stock_item_set( @@ -3084,7 +3084,7 @@ fn build_quests( } // ZERO-STUCK-QUESTS: emit a COLLECT objective ONLY for an OBTAINABLE item — one that drops // from an in-slice creature, drops from an in-slice CHEST, is sold by an in-slice VENDOR - // (042 — the drops-only gate skipped GO/vendor ReqItems → empty objective set → free + // (the drops-only gate skipped GO/vendor ReqItems → empty objective set → free // turn-in on accept), or is the quest's SrcItem (handed over on accept). A genuinely // unobtainable ReqItem (mining/out-of-slice) still gets NO objective → the quest stays // completable on its obtainable subset instead of soft-locking. @@ -3330,7 +3330,7 @@ fn build_items_and_loot( } } // creature_template.VendorTemplateId — column 76 of 87, read off the dump's own CREATE TABLE - // (247; same dump-verified confidence as the `at::` fix). + // (same dump-verified confidence as the `at::` fix). const CT_VENDOR_TEMPLATE_ID: usize = 76; for row in parse_table(dump, "creature_template") { let entry: u64 = field(&row, ct::ENTRY).parse().unwrap_or(0); @@ -3427,7 +3427,7 @@ fn build_items_and_loot( if holy != 0 || fire != 0 || nature != 0 || frost != 0 || shadow != 0 || arcane != 0 { resist_nonzero_count += 1; } - // Spell slots 3-5, completes the 191 proc engine's item half; data plumbing + // Spell slots 3-5, completes the proc engine's item half; data plumbing // only, same id+trigger shape as slots 1-2 above. let sp3: u32 = field(&row, it::SPELLID_3).parse().unwrap_or(0); let spt3: u8 = field(&row, it::SPELLTRIGGER_3).parse().unwrap_or(0); @@ -5605,7 +5605,7 @@ fn push_global_statements(args: &Args, dump: &str, stmts: &mut Vec) -> R // Family "globals": not box-scoped, but its own clear+reload — gated too, so `--family quests` // reloads QUESTS ALONE (not quests-plus-globals). No other family's builder reads these outputs, so // gating their compute too (not just the push) is safe — unlike the entangled quests/items pair. - // "globals" rode by 225 rather than a new dedicated family: `game_areatrigger_teleport` is a small + // Area-trigger teleports ride "globals" rather than a new dedicated family: `game_areatrigger_teleport` is a small // world-wide reference table with the exact same shape as `game_graveyard_zone` right next to it // (global, own clear+reload, nothing else in this function reads its output) — no new family value // earns its keep for one more small table. @@ -5779,12 +5779,12 @@ fn main() -> Result<()> { return terrain::run(&args); } - // `--dump-collision` → the 240 spike: WMO/M2 collision-geometry dry-run (see collision.rs). + // `--dump-collision` → the spike: WMO/M2 collision-geometry dry-run (see collision.rs). if args.dump_collision.is_some() { return collision::run(&args); } - // `--nav` → the 241 nav-grid rasterizer (see nav.rs). + // `--nav` → the nav-grid rasterizer (see nav.rs). if args.nav.is_some() { return nav::run(&args); } diff --git a/importer/src/spell.rs b/importer/src/spell.rs index 72e5e38bf..8970f286f 100644 --- a/importer/src/spell.rs +++ b/importer/src/spell.rs @@ -62,8 +62,8 @@ const E_HEAL_MAX_HEALTH: u8 = 0x16; // heal the target to FULL max health (Lay o const E_TAME_CREATURE: u8 = 0x20; // completed Hunter tame; raw vanilla TameCreature effect (55), lockstep with module taxonomy const E_FEED_PET: u8 = 0x21; // feed a Hunter pet from the explicit item target; raw effect 101 const E_POWER_BURN: u8 = 0x19; -const E_BLINK: u8 = 0x1A; // teleport the caster ~20yd FORWARD along its facing (Mage Blink, 116); reclassified BY NAME from the dead SCRIPT teleport effect (lockstep with module taxonomy) -const E_PERSISTENT_AREA: u8 = 0x1B; // ground-AoE (118, Consecration): spawns a fixed-position game_ground_area whose tick damages hostiles inside; reclassified BY NAME from the ground A_PERIODIC_DAMAGE effect (lockstep with module taxonomy) +const E_BLINK: u8 = 0x1A; // teleport the caster ~20yd FORWARD along its facing (Mage Blink); reclassified BY NAME from the dead SCRIPT teleport effect (lockstep with module taxonomy) +const E_PERSISTENT_AREA: u8 = 0x1B; // ground-AoE (Consecration): spawns a fixed-position game_ground_area whose tick damages hostiles inside; reclassified BY NAME from the ground A_PERIODIC_DAMAGE effect (lockstep with module taxonomy) const E_OPEN_LOCK: u8 = 0x1D; const E_DUEL: u8 = 0x22; // Duel (raw effect 83): p0 is the duel-flag gameobject template entry const E_DISENCHANT: u8 = 0x18; @@ -415,7 +415,7 @@ fn power_word_shield_p1_override(spell_id: u32, name: &str, kind: u8, p1: i32) - p1 } -/// `p1` for an `E_POWER_BURN` effect (117, Mana Burn): the DBC `EffectMultipleValue` (a fraction, 0.5 +/// `p1` for an `E_POWER_BURN` effect (Mana Burn): the DBC `EffectMultipleValue` (a fraction, 0.5 /// for vanilla Mana Burn) carried onto the effect row as basis-points (0.5 -> 50), since `p1` is a /// plain `i32`. The module's `mana_burn_damage` treats `<=0` as 100 (1:1) — so a spell whose /// `EffectMultipleValue` genuinely reads 0 in the DBC (unauthored/placeholder) still deals full @@ -1479,7 +1479,7 @@ fn push_spell_effect_rows( power_word_shield_p1_override(spell_id, name, kind, 0i32) }; let script_id = 0u32; - // [093] data-driven "this energize enters/holds combat": set on Bloodrage (cast 2687 + trickle + // Data-driven "this energize enters/holds combat": set on Bloodrage (cast 2687 + trickle // 29131, BOTH named "Bloodrage") so the E_ENERGIZE / A_PERIODIC_ENERGIZE arms read the flag, not // a spell id. Any energize can opt in by adding the name. let enters_combat = name == "Bloodrage"; @@ -1879,7 +1879,7 @@ fn assemble_spell_sql( )); } - // Trainer offerings (259 INVERSION): the --dump ETL's npc_trainer import is now the PRIMARY + // Trainer offerings: the --dump ETL's npc_trainer import is now the PRIMARY // source of class offerings (full per-class trees, the dump's real costs/reqlevels — an // operator-local.import read like every other --dump field); this curated pass is the // OVERRIDE layer for the "specials" cmangos delivers outside npc_trainer (Consecration, diff --git a/module/build.rs b/module/build.rs index a8a4babc1..45158e244 100644 --- a/module/build.rs +++ b/module/build.rs @@ -156,7 +156,7 @@ const HOOK_EVENTS: &[HookEvent] = &[ "payload.character_guid", "payload.npc_guid", ), - // Work-item 228 (encounter kernel): entry-keyed creature death, once-per-instance HP-threshold + // Encounter kernel: entry-keyed creature death, once-per-instance HP-threshold // crossings (fired by encounter::encounter_hp_probe, not a new core chokepoint), and GO use. hook( "on_creature_death", diff --git a/module/src/bridge.rs b/module/src/bridge.rs index aee230200..8b059e517 100644 --- a/module/src/bridge.rs +++ b/module/src/bridge.rs @@ -6,7 +6,7 @@ //! [`client_command`] reducer AS the player — a command handler has exactly a reducer's authority. //! //! Server→client: [`send`] inserts a [`AddonMessage`] row; the gateway relays it (COORDINATOR -//! connection — the 279 delivery law: addon UI state must survive fat transactions) as an +//! connection — the delivery law: addon UI state must survive fat transactions) as an //! addon-language whisper the client surfaces to addons as `CHAT_MSG_ADDON`. //! //! Core owns `ping`; one installed Package may register a parser, an admitted apply operation and a diff --git a/module/src/combat/death.rs b/module/src/combat/death.rs index cc549468f..0b24ab47b 100644 --- a/module/src/combat/death.rs +++ b/module/src/combat/death.rs @@ -755,7 +755,7 @@ fn roll_corpse_loot( target_guid: u64, entitlement: Option<&crate::loot::tag::DeathEntitlement>, ) { - // STALE-SNAPSHOT PURGE (267, found live; widened by 358): corpse guids can be REUSED — + // STALE-SNAPSHOT PURGE: corpse guids can be REUSED — // `debug_spawn_at_feet` allocates max+1 per entry, and a harness SQL teardown deletes the entity // WITHOUT running the decay reaper — so a fresh kill on a reused guid inherited a long-dead // group's `game_corpse_loot_eligible` rows and split the solo looter's purse with departed @@ -989,7 +989,7 @@ pub(crate) fn is_lethal(health: u32, dmg: u32) -> bool { /// 2. the TARGET's incoming % — `A_MOD_DAMAGE_TAKEN` (Shield Wall, a vulnerability debuff); /// 3. absorb shields (`A_ABSORB`, e.g. Power Word: Shield) — BEFORE the lethal check, so a /// fully-absorbed hit can't kill and drains the shield instead of health; -/// 4. the GM playtest godmode zero (223's `.god`) — keyed on the TARGET only, so a godmode unit's +/// 4. the GM playtest godmode zero (the `.god` command) — keyed on the TARGET only, so a godmode unit's /// OUTGOING damage is untouched. Applied AFTER absorb, so a godmode target's shield still drains /// exactly as it did before (and the zeroed remainder reads as "absorbed"). /// diff --git a/module/src/combat/engage.rs b/module/src/combat/engage.rs index 2456f4929..c5ae540b7 100644 --- a/module/src/combat/engage.rs +++ b/module/src/combat/engage.rs @@ -737,7 +737,7 @@ pub(crate) fn apply_start_ranged_attack( let ranged_weapon = equipped_ranged_weapon(ctx, attacker.guid) .ok_or_else(|| "no ranged weapon equipped".to_string())?; let target = validate_attack_target(ctx, &attacker, target_guid)?; - // Activation CheckCast (097/vanilla): vmangos REJECTS the auto-repeat activation for any hard + // Activation CheckCast (vanilla): vmangos REJECTS the auto-repeat activation for any hard // cast failure — the gateway relays the reason as SMSG_CAST_RESULT and the client drops its // toggle, so the client's auto-repeat state never outlives a loop that could not start. Without // these gates the row armed silently, every shot was suppressed by the same checks in the swing diff --git a/module/src/combat/swing.rs b/module/src/combat/swing.rs index 82d0ff724..53c5e0625 100644 --- a/module/src/combat/swing.rs +++ b/module/src/combat/swing.rs @@ -37,7 +37,7 @@ use super::*; /// aggro/assist/pet passes pair within one instance. So this tick's cost is O(active /// engagements) — it scales with combat, NOT with instance count, and scoping it per instance would /// divide an already-small table while adding a per-row entity fetch. (Known pre-existing edge, not -/// widened here: 224's `teleport_player` can move one side of a live pair cross-instance AFTER +/// widened here: `teleport_player` can move one side of a live pair cross-instance AFTER /// arming; the leash pass then evades the creature on raw coordinate distance.) The tick_ms /// smoothing knob is `tick_creatures`'s per-instance row; melee swing timing is already 100ms /// globally. @@ -149,7 +149,7 @@ fn aggro_pass(ctx: &ReducerContext) { // the player closes: their first in-range swing lands the same tick this pass fires. // A RANGED auto-repeat (Auto Shot / Shoot) retaliates at any distance — but only once a // shot has actually FIRED (the swing tick's `enter_combat` stamped the target IN_COMBAT). - // Arming alone is NOT a hit (097 rev.2): the first shot is seeded ~500ms out and can be + // Arming alone is NOT a hit: the first shot is seeded ~500ms out and can be // rejected/suppressed, and retaliating at arm time let the wolf aggro-then-instantly-evade // on a pull whose first shot never fired ("enters combat, leaves combat immediately"). let is_ranged = atk.ranged_spell_id != 0; @@ -357,7 +357,7 @@ fn resolve_swing(ctx: &ReducerContext) { None }; - // (097/vanilla) A RANGED engagement whose shot comes DUE against a hard blocker (out of range / + // (vanilla) A RANGED engagement whose shot comes DUE against a hard blocker (out of range / // too close / no LoS / not facing) is TORN DOWN, not silently suppressed — the vanilla // auto-repeat rule is that a failed check on a DUE ranged shot interrupts // the loop, and the row's delete relays the server-initiated SMSG_CANCEL_AUTO_REPEAT that @@ -367,7 +367,7 @@ fn resolve_swing(ctx: &ReducerContext) { let ranged_due = ranged.is_some_and(|(_, _, delay, _)| now_ms.wrapping_sub(atk.last_swing_ms) >= delay); - // (097/vanilla) Realtime movement rule — vanilla applies this BEFORE any castability + // (vanilla) Realtime movement rule — vanilla applies this BEFORE any castability // check: a PLAYER who is actually TRANSLATING (MOVE_MASK_MOVING — turning in place does // not count) CANCELS a wand loop outright, and DEFERS an Auto Shot loop: the due shot // re-arms RANGED_INITIAL_SHOT_MS out, over and over while moving, so the first shot after @@ -457,7 +457,7 @@ fn resolve_swing(ctx: &ReducerContext) { /// deliberately SIDE-EFFECT-FREE, because a block means different things to the two swing kinds and /// that decision belongs to the caller: a melee row silently waits for the next tick, while a DUE /// ranged row is torn down (vanilla's auto-repeat interrupt, whose row delete relays the -/// server-initiated SMSG_CANCEL_AUTO_REPEAT that drops the client's toggle). [097]/[243] +/// server-initiated SMSG_CANCEL_AUTO_REPEAT that drops the client's toggle). fn swing_blocked( ctx: &ReducerContext, attacker: &WorldEntity, @@ -495,7 +495,7 @@ fn swing_blocked( } // Auto Shot / wand Shoot have a MINIMUM range (~5 yd): a target in melee range is "too close" // (vanilla SPELL_FAILED_TOO_CLOSE → InterruptSpell, so the player's next melee press is a clean - // single-press swap). Melee has no minimum. [097] + // single-press swap). Melee has no minimum. if is_ranged && dist_sq < MELEE_RANGE_SQ { return true; } @@ -767,7 +767,7 @@ fn split_damage_projection(final_damage: u32, seal_portion: u32) -> (u32, u32) { /// carries `impact_delay_ms`, while the scheduled `ranged_impact` applies the frozen damage through /// the SAME [`apply_hit`] pipeline when the arrow actually lands — so the number, the health drop and /// the projectile arrive together (user bug: "damage lands earlier than the projectile"). Lethality is -/// therefore decided AT IMPACT, which is why this event never claims `killing_blow`. [097] +/// therefore decided AT IMPACT, which is why this event never claims `killing_blow`. /// /// Out of ammo ends the engagement: the row delete relays the server-initiated /// SMSG_CANCEL_AUTO_REPEAT that drops the client's toggle. @@ -886,7 +886,7 @@ fn fire_ranged_shot( } } -/// Scheduled RANGED-projectile impact (097; scheduler-only): the arrow/bullet/bolt lands — apply the +/// Scheduled RANGED-projectile impact (scheduler-only): the arrow/bullet/bolt lands — apply the /// launch-frozen post-mitigation damage through the SAME shared [`apply_hit`] pipeline the melee swing /// uses (health/lethal via the shared kill helpers, rage both ways, weapon/defense skill-ups, /// break-on-damage, threat), then stamp IN_COMBAT on both sides. Guards re-checked at landing: either diff --git a/module/src/creatures/ai.rs b/module/src/creatures/ai.rs index 2dfbce12a..141c808b3 100644 --- a/module/src/creatures/ai.rs +++ b/module/src/creatures/ai.rs @@ -488,7 +488,7 @@ pub(crate) const GLOBAL_TICK_INSTANCE: u64 = u64::MAX; /// proven by `tick_scope_partitions_every_instance_exactly_once`). /// /// ANTI-STRANDING GUARANTEE: an entity can sit in an instance with NO `game_instance` row and NO -/// dedicated tick row (224's `teleport_player` accepts an arbitrary `instance_id` today) — the +/// dedicated tick row (`teleport_player` accepts an arbitrary `instance_id` today) — the /// catch-all's "everything not dedicated" rule means such an entity is ALWAYS covered; a /// strictly-enumerated instance list would freeze it forever. pub(crate) enum TickScope { @@ -862,7 +862,7 @@ mod tests { /// the_documented_shape` convention): `game_creature_family` constructs with named fields exactly /// like the importer's positional SQL INSERT column list (`family_id,name,pet_food_mask, /// pet_talent_type,category`) — a compile-time guard against a silently reordered/renamed column. - /// Purely compile-time: the runtime gates (188's feeding + tameable) don't exist yet, so there is + /// Purely compile-time: the runtime gates (feeding + tameable) don't exist yet, so there is /// nothing behavioral to assert. #[test] fn new_creature_family_table_constructs_with_the_documented_shape() { diff --git a/module/src/creatures/spawn.rs b/module/src/creatures/spawn.rs index 9f4b555fe..6cc1a10cd 100644 --- a/module/src/creatures/spawn.rs +++ b/module/src/creatures/spawn.rs @@ -296,7 +296,7 @@ pub struct GossipOption { pub icon: u32, // cmangos OptionIcon (gossip icon glyph); truncated to u8 on the wire pub text: String, // resolved option label (direct npc_text-style string, or via broadcast_text) pub action: u32, // cmangos OptionType/option_id — see `lyracore_shared::constants::gossip_option` - pub action_menu_id: u32, // cmangos ActionMenuId (submenu target) — stored INERT, never navigated (217 scope) + pub action_menu_id: u32, // cmangos ActionMenuId (submenu target) — stored INERT, never navigated pub cond_type: u32, // see `lyracore_shared::constants::gossip_condition` (0 = always show) pub cond_value1: u32, // cond_type's primary operand (a quest id for the QUEST_* conditions) pub cond_value2: u32, // reserved (unused by the current minimal condition set) diff --git a/module/src/creatures/tick/lifecycle.rs b/module/src/creatures/tick/lifecycle.rs index 81a3d3f62..24378e433 100644 --- a/module/src/creatures/tick/lifecycle.rs +++ b/module/src/creatures/tick/lifecycle.rs @@ -244,7 +244,7 @@ pub(crate) fn pass_gameobject_respawn(ctx: &ReducerContext) -> usize { // Perf catalog 1.21: `by_respawn_at` range-scans ARMED-and-elapsed rows only (`0` = not armed, so // the `1..=now` range skips every ready node and every non-gather GO for free). `respawn_due` stays // as the post-filter, so the predicate — and the result set — are unchanged. `visited` now counts - // the index candidates rather than the whole table; that IS the 230-style evidence number. + // the index candidates rather than the whole table; that IS the evidence number. let mut visited = 0usize; let due: Vec = gos .by_respawn_at() diff --git a/module/src/debug/encounter.rs b/module/src/debug/encounter.rs index ae0a32438..1faed1b87 100644 --- a/module/src/debug/encounter.rs +++ b/module/src/debug/encounter.rs @@ -1,4 +1,4 @@ -//! Encounter kernel levers, operator stand-ins until 227's Deadmines package +//! Encounter kernel levers, operator stand-ins until the Deadmines package //! consumes the primitives for real. Each is a thin `?`-wrapper over the `crate::encounter` fn it //! names, so the runbook can exercise every primitive on a live node without an encounter package. diff --git a/module/src/debug/mod.rs b/module/src/debug/mod.rs index 129135676..cb228e664 100644 --- a/module/src/debug/mod.rs +++ b/module/src/debug/mod.rs @@ -495,7 +495,7 @@ pub fn debug_clear_creatures(ctx: &ReducerContext, map_id: u32) -> Result<(), St Ok(()) } -/// Stamp a `game_ground_area` at `caster_guid`'s position from `spell_id`'s area effect (118 test hook): +/// Stamp a `game_ground_area` at `caster_guid`'s position from `spell_id`'s area effect (test hook): /// looks up the spell header + its periodic-area effect (the one with a radius + period) and inserts the /// zone, exactly as an `E_PERSISTENT_AREA` cast would — so the tick engine can be verified server-side /// without a live client cast. `tick_ground_areas` then damages hostiles inside for `duration_ms`. @@ -1376,7 +1376,7 @@ pub fn debug_accept_quest( crate::actor::accept_quest(ctx, character_guid, giver_guid, quest_entry) } -/// 279 relay-stress: ONE transaction shaped like the 277 killer — the relay-carried rows (a quest +/// Relay-stress: ONE transaction — the relay-carried rows (a quest /// kill credit for `victim_entry` and an item grant). The wire test asserts the client still /// receives SMSG_QUESTUPDATE_ADD_KILL and SMSG_ITEM_PUSH_RESULT out of this fat transaction — the /// delivery guarantee the coordinator relay migration exists to provide. diff --git a/module/src/gc.rs b/module/src/gc.rs index 5c9e1457c..4eda6cbc9 100644 --- a/module/src/gc.rs +++ b/module/src/gc.rs @@ -86,7 +86,7 @@ pub fn reap_movement_events(ctx: &ReducerContext, _schedule: EventReaperSchedule reap!(game_realm_chat_event); // Realm Chat Lines reap!(game_chat_channel_notice_event); // Channel Notices reap!(game_system_message_event); // private Package-originated System Messages - reap!(game_addon_message); // addon-bridge UI messages (184, RLS-scoped) + reap!(game_addon_message); // addon-bridge UI messages (RLS-scoped) reap!(game_roll_event); // unwritten since /roll became a Group Broadcast reap!(game_group_event); // group invite/roster notifications (RLS-scoped) reap!(game_trade_event); // trade-status relay rows (RLS-scoped) diff --git a/module/src/group.rs b/module/src/group.rs index 2e527217d..53130ae14 100644 --- a/module/src/group.rs +++ b/module/src/group.rs @@ -433,7 +433,7 @@ pub struct GroupEvent { /// from in the first place. A guid is the one realm-wide name a character has. The identity /// column stays exactly as it was and still drives the per-player RLS on a world shard; this /// column is what the gateway's realm-core relay filters on (it reads through the owner token, - /// which bypasses RLS, and self-filters per session — the coordinator-relay law of 277/279). + /// which bypasses RLS, and self-filters per session — the coordinator-relay law). // The u64 default MUST be typed: a bare `0` encodes as 4 bytes and `publish` rejects the // migration with "data too short for u64: Expected 8, given 4" (world.rs:127 records the same // rule). Nothing in `cargo test`/`cargo check` validates default-value encoding — only a real diff --git a/module/src/instance.rs b/module/src/instance.rs index 58af0025c..0f2c825f8 100644 --- a/module/src/instance.rs +++ b/module/src/instance.rs @@ -81,7 +81,7 @@ pub(crate) fn entrance_fallback(map_id: u32) -> Option<(u32, f32, f32, f32, f32) dungeon(map_id).map(|d| d.entrance) } -/// Reap an instance after it has been EMPTY this long (30min const, per the 190 design). Vanilla +/// Reap an instance after it has been EMPTY this long (30min const). Vanilla /// keeps an untouched instance alive ~1h; 30min is the item's chosen constant (deviation noted). pub(crate) const INSTANCE_EMPTY_REAP_MICROS: i64 = 30 * 60 * 1_000_000; @@ -330,7 +330,7 @@ pub(crate) fn occupancy_action( /// Where a login whose `pending_instance_id` was reaped lands (NEVER strand, design trap). /// Pure over the two facts the caller derives ([`entrance_fallback`], [`is_dungeon_map`]); -/// alive-or-ghost is orthogonal and preserved per 226's `pending_ghost` rules at the call site. +/// alive-or-ghost is orthogonal and preserved per the `pending_ghost` rules at the call site. #[derive(Debug, PartialEq)] pub(crate) enum StrandingFallback { /// A known dungeon map → its entrance const, instance 0. @@ -386,7 +386,7 @@ pub(crate) fn instance_removal_due( } // =========================================================================================== -// Entry: resolve-or-create (the 225 areatrigger hook's target) +// Entry: resolve-or-create (the areatrigger hook's target) // =========================================================================================== /// A binding/party instance is LIVE for resolution iff its row still exists, is for the right @@ -922,8 +922,8 @@ pub(crate) fn occupied_instances(ctx: &ReducerContext) -> HashSet { } /// Tear one instance down, in the design's order: population (entities + their combat/threat/leg/ -/// loot state, player corpses, GO copies + chest loot) → the 228 encounter-kernel sweep splice → -/// the 229 tick row → bindings → the `game_instance` row itself. Refuses instance 0 (the open +/// loot state, player corpses, GO copies + chest loot) → the encounter-kernel sweep splice → +/// the tick row → bindings → the `game_instance` row itself. Refuses instance 0 (the open /// world) and any instance with a live player inside (belt over the caller's occupancy check — /// same-transaction, so no race). Shared by the reaper and `debug_reap_instance`. pub(crate) fn teardown_instance(ctx: &ReducerContext, instance_id: u64) { @@ -1004,7 +1004,7 @@ pub(crate) fn teardown_instance_inner(ctx: &ReducerContext, instance_id: u64, de crate::creatures::cancel_relay_runs_for_instance(ctx, instance_id); crate::encounter::sweep_encounter_state(ctx, instance_id); - // 5. The dedicated 229 tick row (debug_disarm_instance_tick's body) — coverage of the (now + // 5. The dedicated tick row (debug_disarm_instance_tick's body) — coverage of the (now // empty) id falls back to the catch-all, which is a no-op for a population of zero. let sched = ctx.db.game_creature_move_schedule(); let ticks: Vec = sched diff --git a/module/src/items/tables.rs b/module/src/items/tables.rs index f12ded401..3f50c58e3 100644 --- a/module/src/items/tables.rs +++ b/module/src/items/tables.rs @@ -122,7 +122,7 @@ pub struct ItemTemplate { #[default(0)] pub arcane_res: i32, /// On-use/on-equip spell slots 3-5 (vanilla items carry up to 5; slots 1-2 already cover every - /// 1-10 consumable). Completes the 191 proc engine's item half — no consumer reads these yet + /// 1-10 consumable). Completes the proc engine's item half — no consumer reads these yet /// (data plumbing only). Same shape as `spellid_1`/`spelltrigger_1`: `u32` id, /// `u8` `ItemSpellTriggerType`. END-appended + `#[default(0)]` → additive auto-migration. /// [reference] diff --git a/module/src/loot/rolls.rs b/module/src/loot/rolls.rs index 503f79263..898b44a67 100644 --- a/module/src/loot/rolls.rs +++ b/module/src/loot/rolls.rs @@ -546,7 +546,7 @@ pub(crate) fn settle_roll_grant( /// collide vanishingly rarely, `MAX_TIE_REROLLS` is generous headroom) and announces once; all-pass /// unlocks the row FFA-in-group (no `ROLL_WON` — vanilla shows no "won" line either). The winner is /// granted via `items::grant_item`; on `Err` (inventory full) the row is LEFT, `reserved_for` stamped -/// to the winner (documented winner-locked fallback — 068's mail delivery is the eventual fix). +/// to the winner (documented winner-locked fallback — mail delivery is the eventual fix). /// `votes`/`recipients` are the FULL snapshot (already auto-passed by the caller where needed). fn resolve_roll(ctx: &ReducerContext, roll: &LootRoll, votes: &[LootRollVote], recipients: &[u64]) { if roll.resolved { diff --git a/module/src/quest.rs b/module/src/quest.rs index 62f4c3e3c..6082ce4f0 100644 --- a/module/src/quest.rs +++ b/module/src/quest.rs @@ -3201,7 +3201,7 @@ fn evaluate_share( // never grant a quest the member doesn't qualify for. An offline member has no entity to ask, and // [`lyracore_shared::quest::share_result`] answers TOO_FAR for one before it reads this at all. // The already-held Gate is answered ahead of this line as well, by HAVE_QUEST/FINISH_QUEST. - // ExclusiveGroup + reputation-requirement gates are OUT of scope (194 cuts). + // ExclusiveGroup + reputation-requirement gates are OUT of scope. let gates_ok = member_entity .as_ref() .is_some_and(|e| accept_gates(ctx, e, tmpl).is_ok()); diff --git a/module/src/spell/cast/mod.rs b/module/src/spell/cast/mod.rs index 604f5ed3c..611eebccb 100644 --- a/module/src/spell/cast/mod.rs +++ b/module/src/spell/cast/mod.rs @@ -1,4 +1,4 @@ -//! Targeting + effect dispatch + the cast core (381 split of the old flat 2,999-line `cast.rs`): +//! Targeting + effect dispatch + the cast core (split of the old flat 2,999-line `cast.rs`): //! - `targeting` — `select_targets` / `aura_apply` / `apply_effect` and the per-kind effect handlers. //! - `resolve` — the cast core: `resolve_cast_at` (+ its extracted `check_cast_gates` gate sweep) / //! `resolve_cast` / `begin_cast` / the passive-apply path. diff --git a/module/src/spell/cast/resolve.rs b/module/src/spell/cast/resolve.rs index e45a6501d..2f228be4b 100644 --- a/module/src/spell/cast/resolve.rs +++ b/module/src/spell/cast/resolve.rs @@ -47,7 +47,7 @@ pub(crate) fn resolve_cast_at( // was already sent at begin_cast). false for an instant cast, a channel, a creature cast, and a // triggered cast, which still use the START(0)+GO+COOLDOWN instant packet sequence. is_completion: bool, - // true ONLY on the player CMSG_CAST_SPELL path (088; threaded via begin_cast from the cast_spell + // true ONLY on the player CMSG_CAST_SPELL path (threaded via begin_cast from the cast_spell // reducer): the gateway sent that caster's START/RESULT/GO synchronously, so the relay suppresses // its duplicate. Channel ticks / triggers / creature / debug / item-use casts pass false and the // relay DELIVERS the caster's visual (they never had a synchronous send). @@ -83,7 +83,7 @@ pub(crate) fn resolve_cast_at_typed( // was already sent at begin_cast). false for an instant cast, a channel, a creature cast, and a // triggered cast, which still use the START(0)+GO+COOLDOWN instant packet sequence. is_completion: bool, - // true ONLY on the player CMSG_CAST_SPELL path (088; threaded via begin_cast from the cast_spell + // true ONLY on the player CMSG_CAST_SPELL path (threaded via begin_cast from the cast_spell // reducer): the gateway sent that caster's START/RESULT/GO synchronously, so the relay suppresses // its duplicate. Channel ticks / triggers / creature / debug / item-use casts pass false and the // relay DELIVERS the caster's visual (they never had a synchronous send). @@ -1630,7 +1630,7 @@ pub(crate) fn apply_spell_auras( }; // A talent/login PASSIVE with DBC duration 0 is PERMANENT — the pick is its lifetime; only the // rank supersede or a respec removes it. Without this the ~1s expiry reap deleted a fresh - // Improved Fireball modifier (264 live find: rank-2's A_SPELLMOD aura vanished before the next + // Improved Fireball modifier (rank-2's A_SPELLMOD aura vanished before the next // cast). u32::MAX is the established infinite sentinel (Devotion Aura/stances). Scoped HERE // (the passive-apply path) so combat casts' real durations are untouched. if hdr.duration_ms == 0 { diff --git a/module/src/spell/cast/targeting.rs b/module/src/spell/cast/targeting.rs index 30fe7c88b..8053476a5 100644 --- a/module/src/spell/cast/targeting.rs +++ b/module/src/spell/cast/targeting.rs @@ -643,7 +643,7 @@ pub(crate) fn aura_apply( crate::combo::spend_combo(ctx, caster_guid, cast_target_guid); } - // DEBUFF-APPLICATION THREAT (266/276): landing a hostile aura on a CREATURE adds flat, + // DEBUFF-APPLICATION THREAT: landing a hostile aura on a CREATURE adds flat, // caster-level-scaled threat — the vanilla mechanic that makes Sunder Armor a threat BUILDER // (its armor debuff deals no damage, and threat here was damage-only, so a warrior tank had // nothing but white swings between taunt pins). Uniform over `is_negative`: a debuff that @@ -1611,7 +1611,7 @@ fn convert_health_to_power(ctx: &ReducerContext, caster_guid: u64, amount: i32) } } -/// E_POWER_BURN drain math (117, Mana Burn): floor-at-available — never drains more than the target +/// E_POWER_BURN drain math (Mana Burn): floor-at-available — never drains more than the target /// actually has, and never fails/refuses the cast on a low/empty pool (vanilla drains /// `min(available, requested)`). Pure, unit-tested. fn mana_burn_drain(power: u32, points: i32) -> u32 { diff --git a/module/src/spell/control.rs b/module/src/spell/control.rs index 2dedc4406..6e0762cb4 100644 --- a/module/src/spell/control.rs +++ b/module/src/spell/control.rs @@ -1,5 +1,5 @@ //! Crowd-control model — the composable CC predicate lattice over one shared `has_control_mechanic` -//! scanner, PLUS (381 split) the cast-bar teardown family (`pushback_cast` / `interrupt_cast` / +//! scanner, PLUS the cast-bar teardown family (`pushback_cast` / `interrupt_cast` / //! `break_channel` / `interrupt_cast_and_lock`), moved here from the old flat `cast.rs`: this file //! already called `pushback_cast` from `break_auras_on_damage` below, so co-locating the teardown with //! the rest of the crowd-control model removes a cross-module callback instead of adding one. `mod.rs` @@ -415,7 +415,7 @@ pub(crate) fn is_immune_to_mechanic(ctx: &ReducerContext, unit_guid: u64, mechan } // =========================================================================================== -// Cast-bar teardown (381 split, moved from the old flat `cast.rs`) — pushback / interrupt / channel +// Cast-bar teardown (moved from the old flat `cast.rs`) — pushback / interrupt / channel // break / Kick-style interrupt-and-lock. `break_auras_on_damage` above already calls `pushback_cast`. // =========================================================================================== diff --git a/module/src/spell/taxonomy.rs b/module/src/spell/taxonomy.rs index 0382d072e..45dcdc48c 100644 --- a/module/src/spell/taxonomy.rs +++ b/module/src/spell/taxonomy.rs @@ -106,7 +106,7 @@ pub(crate) const E_DISENCHANT: u8 = 0x18; // disenchant the cast's ITEM target i pub(crate) const E_PERSISTENT_AREA: u8 = 0x1B; // GROUND-AoE (Consecration/Blizzard/Rain of Fire/Flamestrike-patch): an INSTANT effect that, on cast, spawns a fixed-position `game_ground_area` row at the anchor (caster pos for a self/target-0 area; the clicked ground dest once dest-coords are plumbed). Its own scheduled `tick_ground_areas` re-scans the radius each `period_ms` and applies `amount` `school` damage to every hostile inside via the shared apply_resistance→apply_target_damage path (threat/kill/absorb/break-CC reused), reaping the row at `expires_at`. DIRECT per-tick damage (no per-unit aura churn) — the clean-slate choice over mangos' DynamicObject+SpellAuraHolder-add/remove, equivalent for pure-damage areas. The importer reclassifies the ground A_PERIODIC_DAMAGE effect to this kind BY NAME. A future non-damage ground field (heal/slow) extends the emitted descriptor pub(crate) const E_FISH: u8 = 0x1C; // Fishing: gateway-intercepted like E_ENCHANT_ITEM/E_DISENCHANT — CMSG_CAST_SPELL for a spell carrying this kind routes to the `fish` reducer (instant-resolve alpha catch; the bobber/channel flow is the deferred follow-up). Inert in-module (no resolve arm); exists so the gateway routes by DATA, never a spell-id list. pub(crate) const E_OPEN_LOCK: u8 = 0x1D; // Pick Lock: gateway-intercepted like E_FISH (0x1C) — CMSG_CAST_SPELL for a spell carrying this kind routes to the `pick_lock` reducer (unlock a locked GameObject, gated on the caster's Lockpicking 633 skill vs the game_lock required_skill). Inert in-module (NO resolve arm in cast.rs); exists so the gateway routes by DATA, never a spell-id list. 0x1E is reserved for a future E_SUMMON_PORTAL — do NOT reuse. -pub(crate) const E_BLINK: u8 = 0x1A; // teleport the caster ~20yd FORWARD along its facing (Mage Blink, 116): a self-cast position change reusing the teleport core (like E_CHARGE), clamped to the furthest nav-LoS-clear point so it doesn't cross geometry. Root/snare removal rides a separate A_IMMUNITY effect. The importer name-rescues the dead SCRIPT teleport effect (raw 29) to this kind +pub(crate) const E_BLINK: u8 = 0x1A; // teleport the caster ~20yd FORWARD along its facing (Mage Blink): a self-cast position change reusing the teleport core (like E_CHARGE), clamped to the furthest nav-LoS-clear point so it doesn't cross geometry. Root/snare removal rides a separate A_IMMUNITY effect. The importer name-rescues the dead SCRIPT teleport effect (raw 29) to this kind pub(crate) const E_RECALL_HOME: u8 = 0x1F; // teleport the caster to its bound HOME (Hearthstone): a self-cast recall reusing the shared `world::recall_to_home` core, always to instance 0 regardless of the caster's current instance. Data-driven — a consumable's `spellid_1` naming a spell that carries this kind IS "a recall item"; `items::ops::apply_item_use` reads that (not a hardcoded item entry) to skip the normal stack-consumption a used-up consumable takes, since a recall trinket is never consumed. No cost/cooldown gate yet (the vanilla ~10s cast + 1hr CD is the same later follow-up E_BLINK's forward-teleport already defers) pub(crate) const E_DUEL: u8 = 0x22; // Duel (raw effect 83): request a server-authoritative Duel; p0 is the duel-flag gameobject template entry /// Remove the target's active LAND MOUNT (Dazed's mount-removal half). An instant effect that calls the @@ -173,7 +173,7 @@ pub(crate) const A_PROC_TRIGGER: u8 = 0xAB; /// aura 43, ProcTriggerDamage). Same proc profile and same one pass as [`A_PROC_TRIGGER`]; `p0` is the /// school mask ([`P_SCHOOL_MASK`]). The firing arm is not wired yet — the pass logs and spends nothing. pub(crate) const A_PROC_DAMAGE: u8 = 0xB4; -pub(crate) const A_SPELLMOD_FLAT: u8 = 0xAC; // spell modifier, FLAT (264 — DBC aura 107 AddFlatModifier): p0 (P_SPELLMOD_OP) = the SpellModOp, p1 = the 32-bit affected-spell FAMILY mask (DBC EffectItemType), amount = the SIGNED flat value (Improved Fireball: op 10 cast-time, −100ms/rank). Pulled by `spell_mod` at the cast-time/damage seams — a talent passive is pure data, zero per-spell code +pub(crate) const A_SPELLMOD_FLAT: u8 = 0xAC; // spell modifier, FLAT (DBC aura 107 AddFlatModifier): p0 (P_SPELLMOD_OP) = the SpellModOp, p1 = the 32-bit affected-spell FAMILY mask (DBC EffectItemType), amount = the SIGNED flat value (Improved Fireball: op 10 cast-time, −100ms/rank). Pulled by `spell_mod` at the cast-time/damage seams — a talent passive is pure data, zero per-spell code pub(crate) const A_SPELLMOD_PCT: u8 = 0xAD; // spell modifier, PERCENT (DBC aura 108 AddPctModifier): same shape, amount = a signed percent /// DISARM (Warrior Disarm, DBC AuraMod 67 ModDisarm): while active on an ENEMY, the disarmed unit's melee /// swing loses its main-hand weapon — a PLAYER drops to UNARMED damage, a CREATURE (no weapon/unarmed split) diff --git a/module/src/talent.rs b/module/src/talent.rs index aed4023c2..5373eddf3 100644 --- a/module/src/talent.rs +++ b/module/src/talent.rs @@ -47,7 +47,7 @@ pub struct Talent { pub tab_id: u32, /// Rank 2's passive spell id (Talent.dbc `spell_rank[1]`); 0 if this talent has fewer than 2 ranks. /// Applied by `apply_talent_rank` at pick/login (which also SUPERSEDES the lower rank's spell + - /// aura, 031 residual). END-appended `#[default(0)]`. + /// aura). END-appended `#[default(0)]`. #[default(0)] pub rank_spell_2: u32, /// Rank 3's passive spell id (Talent.dbc `spell_rank[2]`); 0 if this talent has fewer than 3 ranks. @@ -566,7 +566,7 @@ fn apply_talent_rank( crate::spell::learn_spell(ctx, guid, owner, rank_spell); let stack = if talent.tab_id != 0 { 1 } else { rank }; // imported: own values; demo: scale by rank crate::spell::apply_spell_auras(ctx, rank_spell, guid, level, stack); - // Server-side rank SUPERSEDE (031 residual): the imported tree carries a DISTINCT spell + // Server-side rank SUPERSEDE: the imported tree carries a DISTINCT spell // per rank, so without this every lower rank's book row lingered (the login // INITIAL_SPELLS re-grew both ranks) and a mapped-kind passive STACKED with the new // rank's aura. Sweep EVERY lower rank (not just N-1) so pre-fix double-rows heal on the @@ -675,7 +675,7 @@ pub(crate) fn do_reset_talents( // Snapshot every learned talent's removable spells BEFORE deleting the rows — the join target // (`game_talent`) is static, so this is a plain read, not a mutation-order hazard. - // 031 residual fix: key on the PER-RANK spells (rank 1..=learned rank), not `def.spell_id` + // Key on the PER-RANK spells (rank 1..=learned rank), not `def.spell_id` // alone — an imported rank-3 talent's live aura + book row belong to `rank_spell_3`, which the // old snapshot missed (rank-3 aura lingered through a respec). Lower ranks are swept too so // pre-supersede legacy book rows heal here as well; the demo tree's shared spell id dedups. @@ -719,8 +719,8 @@ pub(crate) fn do_reset_talents( } crate::spell::recompute_sheet(ctx, character_guid); - // Forget every spell the reset talents had put in the book: granted ABILITIES and (031 residual - // fix) the passive RANK-SPELLS themselves — the book previously kept every passive through a + // Forget every spell the reset talents had put in the book: granted ABILITIES and + // the passive RANK-SPELLS themselves — the book previously kept every passive through a // respec, so the client re-rendered the talents as still learned after relog. for spell_id in granted.iter().chain(passives.iter()) { crate::spell::forget_spell(ctx, character_guid, *spell_id); diff --git a/module/src/trainer.rs b/module/src/trainer.rs index 46cddd1e1..932552aa0 100644 --- a/module/src/trainer.rs +++ b/module/src/trainer.rs @@ -623,7 +623,7 @@ pub(crate) fn apply_trainer_buy( } // Trainer-path only: `learn_spell_with_dependents` also auto-teaches this rank's // ONE-LEVEL `game_spell_learn` dependents (see its doc for why NOT plain `learn_spell` - // universally — it would change createinfo's spell COUNT and break 212's provenance + // universally — it would change createinfo's spell COUNT and break the provenance // count-parity runbook). BuyGrant::Spell(id) => { crate::spell::learn_spell_with_dependents(ctx, caster_guid, owner, id) diff --git a/module/src/world.rs b/module/src/world.rs index 40e1befc1..b3da9155f 100644 --- a/module/src/world.rs +++ b/module/src/world.rs @@ -521,7 +521,7 @@ pub struct EntityMotion { pub cell: i64, } -// ── Anti-cheat: movement plausibility (255, tier 1 — DETECT-AND-FLAG, never reject inline) ──────────── +// ── Anti-cheat: movement plausibility (tier 1 — DETECT-AND-FLAG, never reject inline) ──────────── // The mangos-anticheat lesson: rubber-banding a false positive is worse than the cheat. We LOG anomalies // and leave the position write untouched; a GM tool surfaces the flags. Per-character score = the // COUNT of a guid's rows (no separate counter table — a detect-and-flag MVP doesn't need O(1) reads). @@ -688,7 +688,7 @@ pub struct TeleportEvent { /// single decision point behind both the module's same-map-in-place-update vs. cross-map-despawn branch /// below, and (mirrored gateway-side via live-entity presence post-transaction, since the two can't share /// code across the module/gateway boundary) the `on_teleport` relay's ACK vs. TRANSFER_PENDING/NEW_WORLD -/// choice. Pure. [190/224] +/// choice. Pure. pub(crate) fn is_cross_map_teleport(current_map_id: u32, target_map_id: u32) -> bool { current_map_id != target_map_id } @@ -1834,7 +1834,7 @@ pub(crate) fn persisted_pending_ghost(dead: bool, player_flags: u32, set_offline } /// 1× run speed in basis points — the `.speed`-off value, shared by the live entity's own default and -/// the durable carry column below so "no GM speed" is spelled ONE way. [289] +/// the durable carry column below so "no GM speed" is spelled ONE way. pub(crate) const RUN_SPEED_BP_1X: u32 = 10_000; /// What `(pending_godmode, pending_run_speed_mult_bp)` should be stamped onto the durable Character @@ -1852,7 +1852,7 @@ pub(crate) const RUN_SPEED_BP_1X: u32 = 10_000; /// pass (`creatures::ai::is_aggro_candidate`) — across future sessions, with no in-game indication. /// A session boundary is the natural place for that safety reset, and it costs a GM one `.god` /// re-issue per login. Exactly the `persisted_pending_ghost` discipline, same `set_offline` seam. -/// Pure. [289] +/// Pure. pub(crate) fn persisted_gm_playtest( godmode: bool, run_speed_mult_bp: u32, @@ -1871,7 +1871,7 @@ pub(crate) fn persisted_gm_playtest( /// `(dead, health, player_flags, unit_bytes_1)`: exactly the four fields `do_repop` sets on release /// (dead stays true, health 1 — vanilla's ghost HP — plus the GHOST player flag and the /// UNIT_VIS_FLAG_GHOST render bit OR'd over whatever the fresh build carried, e.g. a Warrior's -/// Battle-Stance byte in `unit_bytes_1`). Pure. [226] +/// Battle-Stance byte in `unit_bytes_1`). Pure. pub(crate) fn ghost_restored_fields(player_flags: u32, unit_bytes_1: u32) -> (bool, u32, u32, u32) { use lyracore_shared::constants::{player_flags as pf, unit_vis_flags}; (