diff --git a/.github/workflows/module-durable.yml b/.github/workflows/module-durable.yml index e93425936..2ac857068 100644 --- a/.github/workflows/module-durable.yml +++ b/.github/workflows/module-durable.yml @@ -100,6 +100,8 @@ 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 + 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/.github/workflows/rust.yml b/.github/workflows/rust.yml index d1a7e0f6d..9dfafc4ec 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -22,6 +22,17 @@ 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: | + 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 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..6dc1b82ea 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 + /// 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] @@ -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/env.rs b/crates/lyracore-shared/src/env.rs index 62aa1f3bd..2afcc03e5 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. @@ -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/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/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/crates/lyracore-shared/src/loot_roll.rs b/crates/lyracore-shared/src/loot_roll.rs index 2eb3ec0f3..3097fd447 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..0ded592ca 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,9 +107,9 @@ 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: +// 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. // ============================================================================================= @@ -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), @@ -374,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 @@ -683,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/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/crates/lyracore-shared/src/quest.rs b/crates/lyracore-shared/src/quest.rs index edb79f265..aa1d37d13 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 ---- #[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..b856e8a77 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 /// @@ -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..7c6d31251 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..4e8a20a8f 100644 --- a/crates/lyracore-shared/src/vmap.rs +++ b/crates/lyracore-shared/src/vmap.rs @@ -1,21 +1,6 @@ -//! 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. +//! 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. pub const VMAP_VERSION: u8 = 1; @@ -37,8 +22,8 @@ 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 -/// isn't bounded, but a single `spacetime call` argument is — see `importer/src/vmap.rs`). +/// `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`. pub const HEADER_BYTES: usize = 5; @@ -126,7 +111,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 +529,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 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 +678,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 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/crates/lyracore-test-support/src/lib.rs b/crates/lyracore-test-support/src/lib.rs index 96673f67f..6ace1aaf4 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}; @@ -260,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) } @@ -325,7 +338,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", @@ -336,7 +349,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/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/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/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/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..4629a30ea --- /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. +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. + +`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 94c39868d..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); } @@ -372,48 +305,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/codec/addon.rs b/gateway/src/codec/addon.rs index 9c718889c..d6928708a 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. @@ -184,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] @@ -209,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; @@ -247,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/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/codec/char.rs b/gateway/src/codec/char.rs index 9bdaed436..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 (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/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 e1b3419f5..743699a27 100644 --- a/gateway/src/codec/entity.rs +++ b/gateway/src/codec/entity.rs @@ -59,11 +59,11 @@ 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. [#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, @@ -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,8 +321,8 @@ 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 (ub2_a, ub2_b, ub2_c, ub2_d) = unpack4(entity.unit_bytes_2); // sheath state in byte 0 (#101) + 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 // unchanged so it agrees with later partial sheet updates. @@ -339,9 +339,9 @@ 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. [#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) @@ -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; @@ -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,10 +500,10 @@ 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 + .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 @@ -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..8ffcff850 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 @@ -154,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 @@ -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/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 52f7892f3..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 { @@ -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/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/movement.rs b/gateway/src/codec/movement.rs index 83cbb9a47..290be3888 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..0ba0c532c 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); @@ -284,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. @@ -319,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] { @@ -553,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; @@ -590,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 { @@ -1409,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). @@ -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(); @@ -1977,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( @@ -2251,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); @@ -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..38c49ebb9 100644 --- a/gateway/src/codec/update_mask.rs +++ b/gateway/src/codec/update_mask.rs @@ -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; @@ -135,8 +135,8 @@ 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 + /// ..)`. 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 a69c0f29e..a29210c1f 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(); @@ -175,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 { @@ -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); @@ -416,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| { @@ -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 1f441f33b..36445880f 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)] @@ -14,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. @@ -356,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 /// @@ -453,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 )); @@ -482,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 @@ -623,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, @@ -763,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 @@ -796,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( @@ -1105,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" ); } @@ -1139,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" ); } @@ -1185,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**. /// @@ -1231,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") @@ -1252,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!( @@ -1270,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()); @@ -1297,181 +1281,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"); @@ -1481,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!( @@ -1637,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 7e1fcecb1..00a6d4cd7 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}; @@ -450,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, } @@ -945,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(), @@ -2383,27 +2389,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..bb1450fa9 100644 --- a/gateway/src/main.rs +++ b/gateway/src/main.rs @@ -25,16 +25,12 @@ mod provision_cli; mod read_deadline; mod realm_core; mod stdb; -#[cfg(test)] -mod test_scan; mod world; 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; @@ -200,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)?; @@ -225,552 +210,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..5c1f1a10e 100644 --- a/gateway/src/realm_core.rs +++ b/gateway/src/realm_core.rs @@ -1,35 +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. 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. +//! 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}; @@ -175,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 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, @@ -329,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, @@ -645,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); @@ -706,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 @@ -834,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. @@ -1550,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(); @@ -1637,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(); @@ -1652,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 \ @@ -1708,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(); @@ -1772,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(); @@ -1801,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, @@ -1900,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) @@ -1938,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." ); @@ -1999,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() @@ -2043,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 @@ -2061,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)); @@ -2086,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, @@ -2096,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)); @@ -2122,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" /// ``` @@ -2142,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() @@ -2265,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)); @@ -2294,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); @@ -2339,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" ); } @@ -2486,101 +2325,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/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..0a40bd44e 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. diff --git a/gateway/src/stdb/connection.rs b/gateway/src/stdb/connection.rs index 67507e4de..e902af469 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,17 @@ 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()`). + // 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", - // 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. + // 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", - // 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", @@ -1247,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", @@ -1263,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", @@ -1422,8 +1382,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 +1451,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 +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 (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 +1781,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 +2386,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 +2395,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 +2408,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 +2723,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 +2839,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 a57f75e61..c66a16171 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. @@ -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 @@ -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/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/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/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/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/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/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/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 eecb72122..ff943b02e 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")); @@ -2522,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, @@ -2559,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`. @@ -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, @@ -2828,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( @@ -3083,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 { @@ -3097,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, @@ -3848,96 +3843,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 +3901,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 @@ -4913,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)); } @@ -4998,28 +4886,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 +5010,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..40c7ef0fa 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>, @@ -3242,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); @@ -3289,20 +3233,20 @@ 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 { 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))); @@ -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)); @@ -7111,360 +7051,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 +7376,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 { @@ -7924,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. @@ -7992,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] @@ -8033,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; @@ -8407,114 +7965,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/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/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 f588cd274..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 @@ -114,8 +108,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 — @@ -326,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) } @@ -404,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(); @@ -1658,10 +1636,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 +1791,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..2de570cf9 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; @@ -3412,32 +3401,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 +3452,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, @@ -4048,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; @@ -4598,12 +4550,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 +5113,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 +5263,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)] @@ -5979,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); @@ -6061,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); @@ -6089,9 +5857,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 +5867,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)] @@ -6312,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 b8e496fc0..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; @@ -1304,39 +1299,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/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..795c65e0d 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 @@ -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, }); - // Phase 2: 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 { - // Phase 3 (§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( )), )?; } - // Phase 4 (§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(); @@ -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/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/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/handlers/query.rs b/gateway/src/world/handlers/query.rs index 5dc6ea970..701a7e9b9 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))?; @@ -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) => { @@ -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..fd2b71484 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..e2647e611 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; 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..11b34cc53 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 + // 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 3227445e0..447a7ceed 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" ); } } @@ -1107,44 +1098,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 @@ -1467,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; @@ -1510,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. @@ -1577,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). @@ -1602,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" @@ -1717,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 {:?}", @@ -1768,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(); @@ -1812,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), } @@ -1848,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, @@ -1916,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(), @@ -2303,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:?}" ); @@ -2675,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..c89c3f7e7 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, @@ -827,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, @@ -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 @@ -1271,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<()>; @@ -1290,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<()>; @@ -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,21 +1325,21 @@ 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). + /// 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 4f5aa1ed4..08f127c17 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..d5b0f3d77 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 d0cf83c91..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, || { @@ -508,46 +476,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 @@ -838,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 @@ -959,95 +884,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 -// `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. - -/// 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 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 1a08ce286..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") @@ -103,3 +98,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" + ); + } +} diff --git a/gateway/tests/schema_parity.rs b/gateway/tests/schema_parity.rs index 79556f520..d43816669 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)] @@ -413,10 +333,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 +482,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('_', ""), @@ -657,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, }); @@ -717,7 +637,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, @@ -1393,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/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/importer/src/collision.rs b/importer/src/collision.rs index c2e946173..d05160141 100644 --- a/importer/src/collision.rs +++ b/importer/src/collision.rs @@ -1,6 +1,6 @@ -//! `--dump-collision ` — work-item 240 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. +//! `--dump-collision `, SPIKE: prove we can read WMO/M2 +//! 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 fd12fa41f..4e04bb3bb 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; 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)?; @@ -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 @@ -265,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); @@ -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,9 +507,9 @@ 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 +/// 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( @@ -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,10 +780,10 @@ 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` +/// 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) { @@ -834,8 +815,8 @@ fn area_sql(table: &DbcAreaTable) -> (Vec, usize) { (stmts, n) } -/// Clear+reload SQL for `game_area_trigger` from `AreaTrigger.dbc` (work-item 209): the geometric -/// half of inn triggers (196), dungeon entrances (190), and quest explore objectives — a trigger +/// Clear+reload SQL for `game_area_trigger` from `AreaTrigger.dbc`: the geometric +/// 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 → @@ -871,7 +852,7 @@ 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 +/// 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` + @@ -1214,10 +1195,10 @@ 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 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 @@ -1266,8 +1247,8 @@ 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 -/// 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`). @@ -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( @@ -2276,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)] @@ -2382,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 { @@ -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..db11ef8c9 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; #520/#521, -//! 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 @@ -105,14 +104,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. @@ -139,7 +138,7 @@ mod npct { 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 @@ -212,7 +211,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 +219,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,8 +231,8 @@ 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 - // per-guid rows). [V] BOTH vintages below are cmangos-1.12-from-knowledge, NOT verified against + // 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: // >= 9 cols: Entry, PathId, Point, PositionX, PositionY, PositionZ, Orientation, WaitTime, ScriptId @@ -322,8 +321,8 @@ mod gz { pub const FACTION: usize = 2; } mod at { - // cmangos `areatrigger_teleport` — VERIFIED against the real dump 2026-07-10 (work-item 245; 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 @@ -345,12 +344,12 @@ 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). 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; @@ -361,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 (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 item's reputation gate. 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 +381,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 +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 (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 +408,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 +459,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), 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 +478,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 @@ -532,7 +530,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,16 +604,9 @@ 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 +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; @@ -629,14 +620,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 +632,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, @@ -709,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 { @@ -737,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 -/// (work-item 208): 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 @@ -784,7 +754,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 +771,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 +805,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 @@ -881,13 +845,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. @@ -904,7 +862,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 +897,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', @@ -1034,15 +992,15 @@ 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) vmap: Option, // client Data/ dir: #520/#521 exact vmap triangle extract+pack+import (see vmap.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) 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 +1012,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 +1033,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 - // 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) family: Option, + // 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). pub(crate) eventai_profile: String, @@ -1178,8 +1136,8 @@ 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 - // real data, not from memory). Prints min/max X/Y/Z + spawn count and returns — writes nothing. + // 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, "--talents" => a.talents = true, @@ -1249,7 +1207,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 +1271,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 +1390,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 +1477,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 +1513,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 +1547,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 +1652,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,16 +1684,16 @@ 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 +/// 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. @@ -1767,7 +1725,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,12 +1736,12 @@ 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 +/// 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 @@ -1818,7 +1776,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 +1816,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 +1825,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,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 @@ -1941,9 +1899,7 @@ fn build_spell_chain_sql(dump: &str, links: &[(u32, u32)]) -> 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 +2000,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 +2068,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 +2096,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,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 (work-item 217 — 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. @@ -2223,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 { @@ -2337,7 +2282,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,8 +2545,8 @@ struct QuestEtl { reward_spells: Vec, reward_choices: Vec, relations: Vec, - // GAMEOBJECT giver relations (work-item 041: 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` @@ -2613,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, - // 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,12 +2591,12 @@ fn parse_ref_pools(dump: &str) -> std::collections::HashMap>, @@ -2686,8 +2631,7 @@ fn resolve_ref_pool( out.push((item, chance, maxcount.max(1))); } } - // Unwind: only the ACTIVE path blocks re-entry — a shared sub-pool referenced from two sibling - // rows expands in both (the visited set is a recursion stack, not a global seen-set — review catch). + visited.remove(&pool_id); out } @@ -2700,11 +2644,11 @@ fn resolve_ref_pool( /// objective-emission gate (`creature_drop_item_set`) and every family's loot-row builder consume the /// SAME parse — they can never diverge on what an in-slice entry actually drops. Pure function of /// `dump`/`table`/`refs`. `reference_loot_template` pools are expanded RECURSIVELY at import -/// (`resolve_ref_pool`, cycle-guarded, decision #2) — a FRESH `visited` set per referencing row, so the +/// (`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 -/// the module can gate the roll on quest possession at roll time (module doc decision #1). A +/// `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 // parse and the emit stay side by side. @@ -2731,7 +2675,7 @@ fn parse_loot_family( let ref_or_min: i64 = field(&row, clt::MINCOUNT_OR_REF).parse().unwrap_or(0); let maxcount: u32 = field(&row, clt::MAXCOUNT).parse().unwrap_or(1); // A NEGATIVE `ChanceOrQuestChance` is cmangos's QUEST-drop marker: the item drops at |chance|%, - // but ONLY for a looter who currently needs it (module doc decision #1) — kept as `quest_only`, + // but ONLY for a looter who currently needs it (see the module doc), kept as `quest_only`, // not folded away. A 0 row is an empty placeholder → skip. let quest_only = raw_chance < 0.0; let chance = raw_chance.abs(); @@ -2757,11 +2701,7 @@ fn parse_loot_family( .sum(); let n_zero = items.iter().filter(|(_, c, _)| *c <= 0.0).count().max(1) as i64; let leftover = (gate_bp as i64 - sum_explicit).max(0); - // Namespace the pool group above any direct groupid AND uniquely per REFERENCE ROW: - // one entry can reference the SAME pool twice at different gate chances, and keying on - // pool_id alone merged those into one pick-one group (review catch). Group ids only need - // uniqueness WITHIN this entry's row set — the current drops length is monotonic across - // reference rows (each pushes ≥1 row), so it's a collision-free discriminator. + let group_id = 1_000_000 + drops.len() as u32; for &(ritem, rchance, rcount) in &items { let item_bp = if rchance > 0.0 { @@ -2820,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( @@ -2848,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( @@ -2921,7 +2861,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 +2959,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 +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 (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 +3053,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. @@ -3144,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. @@ -3265,7 +3205,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 +3310,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). @@ -3390,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); @@ -3411,22 +3351,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 +3377,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 +3417,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 +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 (work-item 213) — 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); @@ -3511,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 (work-item 213) — 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) @@ -3520,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 (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); @@ -3529,10 +3453,10 @@ 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 (#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 +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 (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 +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 (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 +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). 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,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 (issue #24: 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 { @@ -3824,8 +3748,8 @@ 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 -/// answered by the dump's own Valley of Trials / Camp Narache / Shadowglen rows, not from memory). +/// 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)> { parse_table(dump, "playercreateinfo") @@ -3847,13 +3771,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 +3832,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 +/// 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" (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). @@ -3949,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 { @@ -4006,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 (work-item 216'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 @@ -4144,7 +4061,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 +4257,7 @@ fn collect_creature_spawns(dump: &str, scope: &WorldImportScope) -> Vec = Vec::new(); let mut pool_resolved = 0u32; @@ -4377,7 +4294,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 +4503,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 +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` (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 +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 (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 +4777,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 +4841,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 @@ -4983,14 +4900,14 @@ 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() .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(); @@ -5076,9 +4993,8 @@ 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 - // imported chest, not a curated allowlist — work-item 210's scoping now spans the whole live set). + // 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(); let mut go_meeting_stone_rows: Vec = Vec::new(); @@ -5122,7 +5038,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"), @@ -5222,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 (work-item 208) 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; @@ -5263,7 +5179,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). @@ -5452,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. @@ -5684,12 +5600,12 @@ 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. - // "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. @@ -5727,7 +5643,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 +5698,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(); @@ -5863,17 +5779,17 @@ 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); } - // `--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 +5835,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 +5855,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 +5901,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 +5997,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 +6068,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 (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 (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)); @@ -6238,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 @@ -6302,7 +6218,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 +6244,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 +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()); - // 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 +6268,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 +6293,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 +6326,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 +6350,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 +6408,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 +6483,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,8 +6552,8 @@ 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 - /// baseline (byte-identical to the pre-213 shape for an ordinary existing item like the starter + /// bag_family data at all keeps every column at 0, the additive-migration + /// 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] @@ -6716,7 +6632,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 +6720,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 +6866,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,8 +6940,8 @@ mod tests { #[test] fn family_spellmeta_filter_produces_only_spellmeta_family_stmts() { - // `--family spellmeta` (work-item 102, 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); \ @@ -7113,7 +7027,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 +7173,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 +7433,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 +7452,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 +7721,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 +7902,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 +7919,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 +8040,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 +8102,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 +8818,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 +9424,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 +9484,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 +9619,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 +9633,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 +9677,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 +9706,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 +9715,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 +9752,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 +9806,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..e2a3329a6 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..8970f286f 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_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_POWER_BURN: u8 = 0x19; +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; // 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); @@ -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; @@ -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") } @@ -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 @@ -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 (work-item 101 — 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 @@ -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 } } @@ -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). @@ -502,37 +502,21 @@ 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) - // 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.) + // 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 @@ -652,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, @@ -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 → @@ -1498,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"; @@ -1687,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 phase-2 dest plumbing (6067df1) anchors the area at + // 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 // its own follow-up; leaving them A_PERIODIC_DAMAGE keeps them out of the curated kit). @@ -1798,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)]. @@ -1829,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 @@ -1871,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, @@ -1898,9 +1879,9 @@ 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 + // 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 @@ -1935,22 +1916,12 @@ 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; 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,22 +1929,12 @@ 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![ 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}"), ]; @@ -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,8 +2155,8 @@ 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 - // stamp_family calls in main.rs::run_dump). file_hash is "" here: unlike the --dump path + // 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 // cheaply — threading a chain-wide hash would mean re-reading every source MPQ a second time @@ -2958,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), @@ -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 @@ -3193,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); @@ -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). @@ -3262,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, @@ -3280,12 +3237,12 @@ 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 ); - // 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 @@ -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); @@ -3737,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!( @@ -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..8064bfbcf 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,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 (work-item 206 — 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}; @@ -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 (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 @@ -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); @@ -488,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/importer/src/vmap.rs b/importer/src/vmap.rs index 3984ef6bf..9b9f2ffd0 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. 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..45158e244 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. @@ -231,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", @@ -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..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 (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 0683bece2..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( @@ -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( @@ -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 @@ -5776,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); @@ -7860,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..12bf76f77 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. @@ -542,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 } @@ -588,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 @@ -762,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. @@ -859,36 +791,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() { assert_eq!( @@ -904,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!( @@ -919,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); @@ -934,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 @@ -952,88 +840,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}" - ); - } } // =========================================================================================== @@ -1221,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 @@ -1244,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/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/bridge.rs b/module/src/bridge.rs index 36b7bef56..8b059e517 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 @@ -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 @@ -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/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/character.rs b/module/src/character.rs index d2881402d..3599a6d33 100644 --- a/module/src/character.rs +++ b/module/src/character.rs @@ -102,54 +102,36 @@ 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 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 /// `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 + /// 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` /// 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 - /// 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 @@ -162,12 +144,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 a2ce0b436..4de156ba9 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) } @@ -72,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 @@ -232,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, @@ -555,43 +554,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..0b24ab47b 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; @@ -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( @@ -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, @@ -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 @@ -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 @@ -993,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"). /// @@ -1270,34 +1266,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 +1275,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..c5ae540b7 100644 --- a/module/src/combat/engage.rs +++ b/module/src/combat/engage.rs @@ -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 @@ -399,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. @@ -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, @@ -470,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. @@ -743,14 +733,11 @@ 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)?; - // 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 @@ -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() @@ -802,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 @@ -864,25 +848,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/folds.rs b/module/src/combat/folds.rs index 49900f49c..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) @@ -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 47093a0aa..53c5e0625 100644 --- a/module/src/combat/swing.rs +++ b/module/src/combat/swing.rs @@ -29,15 +29,15 @@ 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 /// 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 +/// 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; @@ -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(); @@ -383,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 @@ -393,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 @@ -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, @@ -514,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, @@ -552,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; } @@ -560,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, @@ -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, @@ -825,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. @@ -944,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 @@ -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 @@ -1009,208 +946,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/combat/tables.rs b/module/src/combat/tables.rs index 234fd3edd..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 @@ -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 bb61e7d93..0ed1945bd 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,9 +149,9 @@ 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 -/// (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] +/// (see `importer/src/dbc.rs::area_trigger_sql`). The geometric half of inn triggers +/// ("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] @@ -361,63 +338,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/corpse.rs b/module/src/corpse.rs index 38ab6597a..96f192cb0 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, } @@ -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 6704a7c66..141c808b3 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 @@ -528,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 { @@ -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 + /// 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 @@ -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,11 +858,11 @@ 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. - /// 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() { @@ -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,9 +1242,9 @@ 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 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() { @@ -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/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..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() { @@ -5915,476 +5911,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/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/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/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/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.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/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..6cc1a10cd 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,14 +78,14 @@ 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 // 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, @@ -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 @@ -268,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 { @@ -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 { @@ -312,18 +296,12 @@ 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) } -/// 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, @@ -791,10 +722,10 @@ 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) + 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, @@ -893,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( @@ -941,12 +872,10 @@ 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 — - // 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 @@ -1020,53 +949,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..24378e433 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 -/// 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 +/// 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; @@ -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 -/// 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. +/// 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(); @@ -239,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. /// -/// Work-item 230 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. +/// 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(); @@ -252,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() @@ -281,246 +273,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..7d0135454 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; // =========================================================================================== @@ -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, @@ -164,25 +164,23 @@ 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. /// /// 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] @@ -197,7 +195,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 +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 (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 +255,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,11 +269,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 -/// 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; @@ -291,12 +287,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 +395,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,9 +404,9 @@ 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 +/// 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 { @@ -427,12 +418,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 +/// 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 @@ -453,8 +442,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 +457,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 +476,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 +675,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 +689,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 @@ -1004,49 +966,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/debug/encounter.rs b/module/src/debug/encounter.rs index 2cef89c9e..1faed1b87 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 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. @@ -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/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..bbe8b6742 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}; @@ -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 @@ -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, @@ -97,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] @@ -147,8 +142,8 @@ 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 -/// `debug_set_xp_rate`. OFF = pre-243 straight-line movement + wall-blind aggro/casts. +/// Toggle nav-grid consumption (`game_config.nav_enabled`), upserts row 0 like +/// `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(); @@ -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. @@ -1095,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 ccb8a1174..cb228e664 100644 --- a/module/src/debug/mod.rs +++ b/module/src/debug/mod.rs @@ -1,41 +1,28 @@ -//! 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. //! -//! # 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) +//! # 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 //! 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 +34,6 @@ mod mail; mod meeting_stone; mod package_config; mod readout; -mod repair; mod runtime_script; pub use audit::*; @@ -60,7 +46,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}; @@ -205,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(); @@ -462,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, @@ -480,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 (work-item 223 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, @@ -520,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`. @@ -693,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] @@ -813,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 @@ -836,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), @@ -962,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 { @@ -1128,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 -/// phase-2 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( @@ -1239,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] @@ -1255,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, @@ -1405,17 +1376,10 @@ 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. -/// -/// `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, @@ -1427,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); @@ -1448,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] @@ -1463,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] @@ -1547,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, @@ -1584,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| { @@ -1870,28 +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 (work-item 229'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` (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). + +/// 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` (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 -/// 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( @@ -1905,7 +1863,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)")); @@ -1928,10 +1886,10 @@ 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). +/// 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 { @@ -1995,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 #1): 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] @@ -2063,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 (work-item 216 provenance stamp). + // total. let quest = ctx .db .game_quest_template() 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/encounter.rs b/module/src/encounter.rs index 67e544fb0..d81d15973 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: //! @@ -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] @@ -284,10 +283,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 +343,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 +354,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 +406,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, @@ -475,8 +461,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 +550,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 +566,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 +594,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 +632,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 +785,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 +803,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); @@ -875,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 @@ -889,7 +830,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 +941,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..56e41f886 100644 --- a/module/src/exploration.rs +++ b/module/src/exploration.rs @@ -1,5 +1,4 @@ -//! Exploration / discovery XP (work-item 200; 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 22c51aaaa..7e3845401 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,16 +199,16 @@ 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 + /// 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. @@ -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 +/// 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). @@ -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 +/// 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) { @@ -483,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), @@ -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, @@ -643,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() @@ -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, @@ -808,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)?; @@ -823,13 +780,13 @@ 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); 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() @@ -965,12 +915,12 @@ 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). + // 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()); } @@ -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,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` (work-item 211, 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)?; @@ -1180,14 +1128,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 +1144,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 { @@ -1221,9 +1158,8 @@ 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) - // 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., + 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, cell: lyracore_shared::spatial::cell_id_at(gx_src, gy_src), @@ -1261,7 +1197,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 +1219,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 +1231,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 +1243,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 +1333,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 +1376,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 d6efdf6d0..4eda6cbc9 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 @@ -90,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) @@ -98,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 @@ -160,11 +156,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); @@ -212,27 +208,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/gm.rs b/module/src/gm.rs index 1f6d922ed..431c8797d 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): `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 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. diff --git a/module/src/graveyard.rs b/module/src/graveyard.rs index 0131ba86f..eae36ca20 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,7 +7,7 @@ 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 +/// 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 @@ -26,17 +21,15 @@ 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 -/// `importer/src/main.rs::build_graveyard_zone_sql`); `resolve_graveyard` reads it via `by_zone` to +/// 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] #[table( @@ -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,8 +82,8 @@ const EASTVALE: Graveyard = Graveyard { o: 0.0, }; -// world_safe_locs id ≈80 [V] — Sentinel Hill (Westfall, zone 40). Hand-added for work-item 206 -// (the Westfall 1-20 slice); coords are an UNVERIFIED estimate (this sandbox has no reference +// 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. const SENTINEL_HILL: Graveyard = Graveyard { @@ -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, @@ -191,20 +156,13 @@ pub(crate) fn nearest_of(candidates: &[Graveyard], px: f32, py: f32) -> Option 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, @@ -270,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() @@ -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 c5683fca6..53130ae14 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); } @@ -450,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 @@ -466,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() @@ -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)` 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 @@ -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() { @@ -3578,141 +3535,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..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( @@ -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..55f8e6fcb 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}; @@ -36,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 @@ -57,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 { @@ -687,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 @@ -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, @@ -1704,46 +1683,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/helpers.rs b/module/src/helpers.rs index 2862be273..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 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,8 +182,8 @@ 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 -/// entity's own `instance_id`; every slice-1 caller is at instance 0, so behavior is unchanged. +/// default) so the compiler finds every call site, pass the ACTING +/// entity's own `instance_id`; every caller is at instance 0, so behavior is unchanged. pub(crate) fn entities_near( ctx: &ReducerContext, map_id: u32, @@ -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..2cbf75057 100644 --- a/module/src/hooks.rs +++ b/module/src/hooks.rs @@ -4,8 +4,8 @@ //! `build.rs`'s marker scan (see the `game_hook` / `game_tick_pass` macro docs in `lib.rs`). Each //! 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. +//! reducer-internal fns core code uses (grant, damage, spawn, ...), but there is no veto/fold. +//! Hooks cannot change 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 @@ -188,16 +188,16 @@ 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). 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,11 +228,11 @@ 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 -/// ROW's own (190 slice 2: rows are instance-tagged; per-instance copies carry their instance, +/// ROW's own (rows are instance-tagged: per-instance copies carry their instance, /// static rows carry 0) — handlers key state checks AND primitives off it directly. pub struct GoUsedPayload { pub go_guid: u64, 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 236e94d4d..0f2c825f8 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. @@ -166,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; @@ -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. 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,9 +328,9 @@ 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. +/// 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. @@ -490,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 @@ -526,7 +422,7 @@ fn bind_character(ctx: &ReducerContext, character_guid: u64, instance_id: u64, m }); } -/// The dungeon-entry chokepoint (190 slice 2): resolve which instance of `target_map` this +/// The dungeon-entry chokepoint: resolve which instance of `target_map` this /// character enters — party's live instance → own live binding → create — enforcing the 5-player /// cap at trigger time. Solo entry allowed (binds to the character, `party_id = 0`). A stale /// binding (instance reaped or reset-flagged) self-heals: the row is dropped and resolution falls @@ -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" ); @@ -876,7 +749,7 @@ pub(crate) fn create_instance_with_id( } // =========================================================================================== -// Reap (slice 3) — scheduled reducer + shared teardown +// Reap — scheduled reducer + shared teardown // =========================================================================================== // =========================================================================================== @@ -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() { @@ -1054,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) { @@ -1130,13 +998,13 @@ pub(crate) fn teardown_instance_inner(ctx: &ReducerContext, instance_id: u64, de gos.guid().delete(guid); } - // 4. Encounter-kernel state — the 228 splice (documented on sweep_encounter_state): tracked + // 4. Encounter-kernel state (splice documented on sweep_encounter_state): tracked // waves (their untagged spawn rows MUST die here or they'd respawn into instance 0), // encounter state + HP fired-marks, equip rows. 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 @@ -1184,7 +1052,7 @@ pub(crate) fn teardown_instance_inner(ctx: &ReducerContext, instance_id: u64, de } // =========================================================================================== -// reset_instance — the party-leader / solo reset verb (slice 3 item 8) +// reset_instance — the party-leader / solo reset verb // =========================================================================================== // Live only under `debug_reducers`: `debug_reset_instance` is its sole caller. @@ -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 ); } @@ -1455,164 +1315,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 { @@ -1670,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() { @@ -1704,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), @@ -1744,74 +1440,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 +1481,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..62aaf4fb6 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: @@ -441,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 = { @@ -575,7 +568,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 +601,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 +631,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..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 } @@ -744,7 +729,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 +742,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). @@ -877,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..ea1288492 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..3f50c58e3 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 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] @@ -131,8 +122,8 @@ 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 - /// (work-item 213: data plumbing only). Same shape as `spellid_1`/`spelltrigger_1`: `u32` id, + /// 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] #[default(0)] @@ -148,15 +139,15 @@ 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)] pub required_skill: u32, #[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, + /// Reputation gate (cmangos `RequiredReputationFaction`/`RequiredReputationRank`). + /// 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, @@ -175,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)] @@ -214,8 +205,8 @@ 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 - /// model they gate on). Set `true` at grant time for a BoP-templated item (quest reward, starter + /// traded or mailed away (trade and mail enforcement gate on this + /// column). 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 /// never flips this today (binds-on-use is a future add). @@ -311,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 8148a3934..87013770c 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`. @@ -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 @@ -225,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 -/// Phase 2 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) => { @@ -309,15 +300,11 @@ 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 +// 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; @@ -331,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; @@ -340,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; @@ -378,6 +365,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; @@ -401,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)] @@ -415,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 @@ -440,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 @@ -472,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/load.rs b/module/src/load.rs index e72d933f4..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!( @@ -311,65 +309,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..e1118957c 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,32 +53,31 @@ 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 (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 - // 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, - // 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 + // 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 - // 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 + // 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. #[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 // 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). @@ -144,10 +114,10 @@ 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), - // never re-evaluated later. `#[default(false)]` so existing rows (imported pre-210, or seeded) keep + // `killer_needs_item`). Gated at ROLL TIME against the CREDITED KILLER (see the module doc), + // 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, @@ -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) 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,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 (work-item 187 slice 0: `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. @@ -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 -/// 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 +/// 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 +/// (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] @@ -913,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")); } @@ -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,7 +1089,7 @@ 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 @@ -1183,10 +1126,10 @@ 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 (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 { @@ -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,10 +1329,10 @@ 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 + /// 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() { @@ -1486,32 +1409,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..898b44a67 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: 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 +/// 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. @@ -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,12 +541,12 @@ 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 /// 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 { @@ -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 ("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 +/// 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 @@ -1127,84 +1069,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..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); @@ -1186,7 +1184,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 +1275,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..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 @@ -363,39 +358,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/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/motion.rs b/module/src/motion.rs index 10d99ee0f..d9df99f05 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,8 +117,8 @@ 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 -/// opcode is ALWAYS a state change"), one layer down — it is deliberately the SAME classification, +/// 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. pub(crate) fn is_discrete(opcode: u16) -> bool { @@ -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, } @@ -478,10 +475,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 { @@ -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!( @@ -662,77 +657,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..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 } @@ -339,157 +333,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,86 +369,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 - /// 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)); @@ -616,367 +379,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/nav.rs b/module/src/nav.rs index a2360fa6a..b6f36b639 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 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 @@ -202,9 +202,9 @@ 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 -// 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 +// Runtime consumption, the module-side wrappers over `lyracore_shared::nav`'s +// 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/debug/repair.rs b/module/src/operations.rs similarity index 78% rename from module/src/debug/repair.rs rename to module/src/operations.rs index d6beabe67..6254169db 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> { @@ -46,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); @@ -151,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 { @@ -185,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 { @@ -236,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() @@ -257,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 { @@ -270,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(); @@ -364,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 @@ -399,16 +371,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/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/professions.rs b/module/src/professions.rs index 196c971f1..48ece5c03 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), @@ -163,11 +163,11 @@ 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 - // 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. @@ -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 @@ -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); @@ -410,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). @@ -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/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/quest.rs b/module/src/quest.rs index 31ef90d18..6082ce4f0 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), 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,8 +230,8 @@ 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 - // auto-migration) --- + // --- 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 // "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 (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); } @@ -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 { @@ -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: 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,15 +2783,15 @@ 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 -/// 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] @@ -3036,19 +3036,19 @@ pub(crate) fn enter_sessionless_areatrigger( ) } -/// The shared core behind [`enter_areatrigger`] and `debug_enter_areatrigger` (work-item 225). 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 +/// The shared core behind [`enter_areatrigger`] and `debug_enter_areatrigger`. Quest +/// 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 /// 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`. @@ -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()); @@ -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_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/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 a07eda10c..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 @@ -260,35 +231,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/reputation.rs b/module/src/reputation.rs index 17646edfb..acbc789dd 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. @@ -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( @@ -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/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/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 fc5ec3ff9..c6d32c7d8 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,10 +222,10 @@ 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). + // Creature EventAI: the fixture on-aggro barks (Kobold/Defias/Hogger). crate::creatures::seed_on_aggro_fixtures(ctx); } @@ -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, @@ -612,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), @@ -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, @@ -647,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), @@ -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, @@ -688,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), @@ -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, @@ -723,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), @@ -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 @@ -1252,13 +1243,6 @@ fn seed_spell_registry(ctx: &ReducerContext) { 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,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 (work-item 192) — 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); } @@ -1479,7 +1463,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 +1491,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 +1514,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; @@ -1563,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). @@ -1580,10 +1561,10 @@ 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`). + // 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 { @@ -1628,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() @@ -1653,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() @@ -1708,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 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 @@ -1916,10 +1897,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 { @@ -1998,43 +1976,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] @@ -2093,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| { @@ -2204,131 +2145,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/seed/fixtures.rs b/module/src/seed/fixtures.rs index 317e7e782..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 @@ -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; 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 @@ -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() { @@ -1317,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. /// @@ -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). @@ -1758,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/skill.rs b/module/src/skill.rs index fdba95600..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] @@ -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 @@ -748,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 @@ -804,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). @@ -817,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 @@ -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,17 +1092,17 @@ 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). -/// 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 { @@ -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/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 08627f002..2f228be4b 100644 --- a/module/src/spell/cast/resolve.rs +++ b/module/src/spell/cast/resolve.rs @@ -47,12 +47,12 @@ 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). client_initiated: bool, - // The clicked GROUND point (118 phase 2), 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. @@ -83,12 +83,12 @@ 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). client_initiated: bool, - // The clicked GROUND point (118 phase 2), 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. @@ -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( @@ -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( @@ -1159,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. @@ -1206,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. @@ -1219,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() @@ -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 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)>, @@ -1461,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). @@ -1642,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 a0a3a4b4b..8053476a5 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}; @@ -19,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. @@ -42,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 { @@ -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. 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 { @@ -305,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 (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); @@ -449,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 @@ -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, @@ -681,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 @@ -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. 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 @@ -974,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) = @@ -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 @@ -1322,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. @@ -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; @@ -1661,14 +1611,14 @@ 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 { (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. @@ -1745,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 @@ -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. `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)>, ) { @@ -1815,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 @@ -1840,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 @@ -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()) @@ -2185,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/control.rs b/module/src/spell/control.rs index 77985659d..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` @@ -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 @@ -418,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`. // =========================================================================================== @@ -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() { @@ -878,74 +875,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/effects.rs b/module/src/spell/effects.rs index 3f288bd6b..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` @@ -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..091c62567 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 @@ -371,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) { @@ -438,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 @@ -556,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 { @@ -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 1464a70fd..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; @@ -1059,140 +1049,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..1434c3d45 100644 --- a/module/src/spell/scheduler.rs +++ b/module/src/spell/scheduler.rs @@ -38,10 +38,10 @@ 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 (118 phase 2): 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 @@ -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 { @@ -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!( @@ -332,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 @@ -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 @@ -752,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 @@ -842,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 @@ -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; @@ -939,38 +921,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/spellbook.rs b/module/src/spell/spellbook.rs index 451c02793..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); } @@ -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 d1e5adf35..cb32233b7 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); @@ -936,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, @@ -964,8 +924,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 +1004,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 +1082,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/tables.rs b/module/src/spell/tables.rs index 022b484da..00e46d238 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] @@ -56,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)] @@ -120,8 +112,8 @@ pub struct SpellEffect { pub enters_combat: bool, } -/// One REAGENT a spell consumes (work-item 282: the real multi-reagent recipe model that replaced -/// the hardcoded `cast::RECIPES` map). Filled by the importer from `Spell.dbc` `Reagent[1-8]` / +/// 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 /// gate resolves by data instead of a hardcoded id list. Module-private (the craft reducer reads @@ -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,17 +390,17 @@ 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, - // 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. @@ -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, @@ -600,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 @@ -627,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 @@ -647,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] @@ -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: 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..45dcdc48c 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; @@ -96,17 +96,17 @@ 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 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_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 +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): 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) @@ -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 74c278e31..0e2ab011d 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)); @@ -92,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() { @@ -102,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); } @@ -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!( @@ -558,36 +552,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. @@ -748,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 97b7aef7c..20636fbfd 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, @@ -252,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 @@ -265,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). @@ -284,89 +269,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/talent.rs b/module/src/talent.rs index 7c1dfcdb3..5373eddf3 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), @@ -75,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. @@ -594,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 @@ -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 { @@ -711,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. @@ -755,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); @@ -774,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/taxi.rs b/module/src/taxi.rs index 9709c7aa7..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, } @@ -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/terrain.rs b/module/src/terrain.rs index 476108362..a952bead0 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,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 (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. 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 +49,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 +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 -/// work-item 200, 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 (work-item 209 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/test_scan.rs b/module/src/test_scan.rs index 6395776bb..bf2033794 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 @@ -446,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/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 92b8bc961..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, @@ -998,25 +992,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 +1040,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..932552aa0 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 @@ -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() @@ -523,13 +500,13 @@ 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` // 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) { @@ -658,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) @@ -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. @@ -802,32 +767,7 @@ 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 + /// 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/harness.rs b/module/src/transfer/harness.rs index 7cf757b96..3f382ab23 100644 --- a/module/src/transfer/harness.rs +++ b/module/src/transfer/harness.rs @@ -1,73 +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 a source scan still covers, and why**: -//! - 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. -//! * **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}; @@ -99,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>, @@ -120,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. 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. @@ -249,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 @@ -672,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] @@ -713,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); @@ -728,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!( @@ -742,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" @@ -812,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] @@ -884,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); @@ -958,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 { @@ -985,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); @@ -1015,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" ); } @@ -1267,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); @@ -1389,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); @@ -1405,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 \ - hardcoded 0 was a #36 mutation that left the whole suite green" + "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"), @@ -1456,15 +1343,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( @@ -1521,7 +1402,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); @@ -1559,12 +1440,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" ); } @@ -1590,13 +1466,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); @@ -1790,7 +1660,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"); @@ -1893,11 +1762,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); } @@ -1931,11 +1796,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); @@ -1965,7 +1827,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); @@ -1998,7 +1859,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 4e3d2333e..f1a6601e8 100644 --- a/module/src/transfer/mod.rs +++ b/module/src/transfer/mod.rs @@ -1,152 +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 source-scan tripwires | -//! | `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. Pinned by `owner_identity_is_regenerated_at_the_destination_never_carried`. -//! * **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: 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. -//! -//! # 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::*; @@ -287,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); } @@ -721,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) } @@ -791,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; @@ -1015,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; @@ -1032,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 @@ -1047,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); @@ -1070,19 +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). 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 -/// 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, } @@ -1141,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); @@ -1298,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, @@ -1421,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 @@ -1717,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. // @@ -1725,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: @@ -2105,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. // @@ -2157,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 d4f1faa13..abd3dc553 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. @@ -40,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!( @@ -57,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." ); @@ -147,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(); @@ -257,43 +239,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 { @@ -630,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); } @@ -697,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 @@ -877,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), @@ -956,325 +894,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 +946,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 // ------------------------------------------------------------------------------------- @@ -1380,7 +957,7 @@ fn the_in_transit_predicate_is_the_login_fence_itself() { /// 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. /// @@ -1413,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 { @@ -1431,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(); @@ -1509,18 +1058,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 -/// 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![ @@ -1562,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!( @@ -1571,44 +1107,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 +1393,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). @@ -2227,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 \ - (#22): 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." ); @@ -2254,14 +1437,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/transfer/transport.rs b/module/src/transfer/transport.rs index 4f34727eb..0d9f8763b 100644 --- a/module/src/transfer/transport.rs +++ b/module/src/transfer/transport.rs @@ -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", @@ -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, @@ -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, @@ -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 4f6eea250..73871f110 100644 --- a/module/src/tripwires.rs +++ b/module/src/tripwires.rs @@ -1,38 +1,19 @@ -//! 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. - -/// 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`. +//! The spatial and fence scanners share one engine, [`crate::test_scan::raw_table_reads`]. + #[cfg(test)] pub(crate) mod character_owned_tripwire { use std::fs; @@ -222,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(); @@ -264,7 +241,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 +607,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 \ @@ -888,13 +866,11 @@ 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. - ("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"), + // 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. - ("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"), @@ -905,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. - ("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"), + // 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 @@ -927,16 +901,22 @@ 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) } + #[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 +949,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 \ @@ -1016,16 +996,15 @@ 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 - /// .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. + /// 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` - /// column itself (`row.shard`), and the ROW TYPE — because `let RegionAssignment { shard, .. } + /// 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 @@ -1066,23 +1045,26 @@ mod partition_discipline_tripwire { out } - /// 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 - /// `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. + #[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")]); + } + + /// 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"); @@ -1116,8 +1098,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,17 +1141,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. -/// -// 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. +/// 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. #[cfg(test)] mod character_fence_tripwire { use crate::test_scan::repo_root; @@ -1182,13 +1158,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/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/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"), @@ -1199,9 +1174,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 @@ -1243,12 +1216,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(", @@ -1268,10 +1235,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) @@ -1283,6 +1246,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(); @@ -1312,9 +1284,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 \ @@ -1358,66 +1331,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 @@ -1429,14 +1342,12 @@ pub(crate) mod print_macro_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 @@ -1457,12 +1368,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] @@ -1478,24 +1410,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 \ @@ -1506,6 +1429,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()); + } } // ================================================================================================= @@ -1525,8 +1463,8 @@ mod gc_reap_tripwire { /// `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)] @@ -1631,7 +1569,7 @@ 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 @@ -1801,7 +1739,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 \ @@ -1884,10 +1823,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\ @@ -1906,9 +1841,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 = @@ -1929,305 +1861,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)] @@ -2297,7 +1930,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 ") ); } diff --git a/module/src/vmap.rs b/module/src/vmap.rs index 819d52e9c..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. @@ -1311,107 +1302,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..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. @@ -931,35 +928,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..b3da9155f 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; @@ -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 @@ -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,16 +321,10 @@ 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 + /// 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. @@ -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. @@ -537,9 +521,9 @@ 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 (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 @@ -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 @@ -662,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. @@ -719,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 } @@ -987,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, @@ -1040,21 +1006,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 +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 (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 +1065,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)`). @@ -1156,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); @@ -1165,7 +1116,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 @@ -1187,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 { @@ -1261,12 +1212,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 +1233,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 +1298,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 +1348,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 +1379,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, @@ -1480,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)] @@ -1526,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. @@ -1560,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. @@ -1578,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 @@ -1589,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 @@ -1672,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). @@ -1680,10 +1602,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 @@ -1722,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, @@ -1856,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, }); @@ -1879,16 +1797,16 @@ 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) - // ≈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); @@ -1910,17 +1828,17 @@ 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 } /// 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 -/// row for this persist — the SCOPE DECISION of work-item 289, 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 @@ -1934,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, @@ -1953,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}; ( @@ -2008,15 +1926,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 +2013,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: 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 +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 (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 @@ -2143,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; @@ -2254,7 +2160,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 +2195,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!( @@ -2347,11 +2250,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() { @@ -2405,9 +2304,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), @@ -2455,61 +2352,7 @@ 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) ------------------------------ + // ---- 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 @@ -2538,7 +2381,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() { @@ -2560,8 +2403,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; @@ -2584,18 +2425,15 @@ 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" ); } - // ---- 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 @@ -2604,7 +2442,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. @@ -2620,77 +2458,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 +2536,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}" - ); - } } diff --git a/module/src/xp.rs b/module/src/xp.rs index 8351ea213..bafe6c13e 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 @@ -276,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. @@ -287,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] @@ -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(); 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/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/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/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/mount.rs b/module/tests/mount.rs new file mode 100644 index 000000000..2ab15f73d --- /dev/null +++ b/module/tests/mount.rs @@ -0,0 +1,179 @@ +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 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 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) { + 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); + + 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); + + 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); +} + +#[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"); + } +} 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/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"); +} 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() +}