diff --git a/.github/workflows/module-durable.yml b/.github/workflows/module-durable.yml index 2ac857068..13758367e 100644 --- a/.github/workflows/module-durable.yml +++ b/.github/workflows/module-durable.yml @@ -95,7 +95,7 @@ jobs: if: matrix.shard == 0 run: | cargo test -p lyracore-gateway --bin lyracore-gateway stdb::account_sessions::tests::independent_coordinators_preserve_the_winner_after_delayed_cleanup -- --ignored --exact - cargo test -p lyracore-gateway --bin lyracore-gateway world::tests::competing_world_sessions_close_the_old_socket_without_removing_the_winner -- --ignored --exact + cargo test -p lyracore-gateway --bin lyracore-gateway world::tests::session_tests::competing_world_sessions_close_the_old_socket_without_removing_the_winner -- --ignored --exact cargo test -p lyracore-gateway --bin lyracore-gateway stdb::subscriptions::character_gone_durable_tests::another_gateway_waits_for_the_transfer_then_cleans_the_deleted_character -- --ignored --exact 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 @@ -103,6 +103,10 @@ jobs: 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 + - name: Run Benilla against private Module instances + if: matrix.shard == 0 + run: cargo test --locked -p lyracore-gateway --bin lyracore-gateway benilla_durable_ -- --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 if: matrix.shard == 1 diff --git a/CONTEXT.md b/CONTEXT.md index 0a51f06e2..ae91d7e3d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -948,7 +948,7 @@ _Avoid_: matchmaking, auto-invite ### World clock and weather **Realm Clock**: -The wall clock the realm runs on, always UTC. There is no realm-timezone setting, so no part of the realm reads host-local time. The Gateway packs it into `SMSG_LOGIN_SETTIMESPEED` once per world entry and the client advances it alone afterwards; the Module reads the same clock to pick the weather season. +The wall clock the realm runs on, always UTC. There is no realm-timezone setting, so no part of the realm reads host-local time. The Gateway packs it into `SMSG_LOGIN_SETTIMESPEED` once per world entry and the client advances it alone afterwards. It also answers `CMSG_QUERY_TIME` with Unix seconds in `SMSG_QUERY_TIME_RESPONSE`, so the client can interpret timed quest deadlines. The Module reads the same clock to pick the weather season. _Avoid_: server time, game time, local time **Zone Weather**: diff --git a/Cargo.lock b/Cargo.lock index 077766239..00ea63a5b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -284,6 +284,29 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "benilla-protocol" +version = "0.2.0" +source = "git+https://github.com/samwhosung/benilla.git?rev=c2fc3cf304075a0fa1c9accffaf5efb99be9edfe#c2fc3cf304075a0fa1c9accffaf5efb99be9edfe" +dependencies = [ + "anyhow", + "benilla-srp", + "clap", + "flate2", + "sha1", + "strum 0.26.3", +] + +[[package]] +name = "benilla-srp" +version = "0.2.0" +source = "git+https://github.com/samwhosung/benilla.git?rev=c2fc3cf304075a0fa1c9accffaf5efb99be9edfe#c2fc3cf304075a0fa1c9accffaf5efb99be9edfe" +dependencies = [ + "num-bigint", + "rand 0.8.7", + "sha1", +] + [[package]] name = "binrw" version = "0.15.2" @@ -1801,6 +1824,8 @@ name = "lyracore-gateway" version = "0.1.0" dependencies = [ "anyhow", + "benilla-protocol", + "benilla-srp", "blake3", "dhat", "env_logger", @@ -1813,6 +1838,7 @@ dependencies = [ "serde_json", "spacetimedb-lib", "spacetimedb-sdk", + "syn 2.0.119", "tokio", "wow_login_messages", "wow_srp", @@ -3087,7 +3113,7 @@ dependencies = [ "spacetimedb-lib", "spacetimedb-primitives", "spacetimedb-sats", - "strum", + "strum 0.25.0", "thiserror 1.0.69", ] @@ -3342,7 +3368,16 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" dependencies = [ - "strum_macros", + "strum_macros 0.25.3", +] + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros 0.26.4", ] [[package]] @@ -3358,6 +3393,19 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.119", +] + [[package]] name = "subtle" version = "2.6.1" diff --git a/crates/lyracore-shared/src/env.rs b/crates/lyracore-shared/src/env.rs index 2afcc03e5..812b351a4 100644 --- a/crates/lyracore-shared/src/env.rs +++ b/crates/lyracore-shared/src/env.rs @@ -6,6 +6,9 @@ /// must be combined with the water-surface test in [`is_submerged`]. pub const MOVEMENT_FLAG_SWIMMING: u32 = 0x0020_0000; +/// Build-5875 transport movement carries a full guid and relative pose. +pub const MOVEMENT_FLAG_ON_TRANSPORT: u32 = 0x0200_0000; + /// Default player collision height. pub const UNDERWATER_HEAD_HEIGHT: f32 = 2.0; @@ -89,15 +92,14 @@ pub fn fall_damage(fall_time_ms: u32, max_health: u32) -> u32 { } /// `fall_time` (u32 ms) out of a raw vanilla MovementInfo body: flags u32 LE, time u32, x/y/z/o -/// 16 B, then [transport guid u64 + pos 16 B if flag 0x0200], [pitch f32 if SWIMMING 0x0020_0000], +/// 16 B, then [transport guid u64 + pos 16 B if flag 0x02000000], [pitch f32 if SWIMMING 0x0020_0000], /// then fall_time. `None` on a short body or an on-transport mover (no fall damage on transports). /// NOTE the wire TYPE: cmangos reads fall time as u32 MILLISECONDS; gtker's MovementInfo types the /// same 4 bytes f32 (a field-type lie, like its field-name ones) — this parser and any gtker-side /// consumer must agree on the raw-u32 reading (`f32::to_bits` at the gtker boundary). pub fn fall_time_from_movement_info(body: &[u8]) -> Option { - const ON_TRANSPORT: u32 = 0x0200; let flags = u32::from_le_bytes(body.get(0..4)?.try_into().ok()?); - if flags & ON_TRANSPORT != 0 { + if flags & MOVEMENT_FLAG_ON_TRANSPORT != 0 { return None; } let mut off = 4 + 4 + 16; // flags + time + x/y/z/o @@ -133,7 +135,7 @@ mod tests { swim.extend_from_slice(&2000u32.to_le_bytes()); assert_eq!(fall_time_from_movement_info(&swim), Some(2000)); // transport: no fall damage - let mut tr = 0x0200u32.to_le_bytes().to_vec(); + let mut tr = 0x0200_0000u32.to_le_bytes().to_vec(); tr.extend_from_slice(&[0u8; 60]); assert_eq!(fall_time_from_movement_info(&tr), None); // short body: None, never a panic diff --git a/crates/lyracore-shared/src/item.rs b/crates/lyracore-shared/src/item.rs index 808399381..cda510291 100644 --- a/crates/lyracore-shared/src/item.rs +++ b/crates/lyracore-shared/src/item.rs @@ -27,12 +27,16 @@ pub enum ItemRefusal { ItemNotUsable, /// The action is blocked for now: a live bandage cooldown, or a cast Gate. NotRightNow, + /// The item template forbids destruction. + Indestructible, + /// An equipped bag still contains items. + BagNotEmpty, /// The Module could not act and has no gameplay reason to name. Internal, } impl ItemRefusal { - pub const ALL: [Self; 12] = [ + pub const ALL: [Self; 14] = [ Self::ItemNotFound, Self::InventoryFull, Self::WrongSlot, @@ -44,6 +48,8 @@ impl ItemRefusal { Self::BankUnavailable, Self::ItemNotUsable, Self::NotRightNow, + Self::Indestructible, + Self::BagNotEmpty, Self::Internal, ]; @@ -60,6 +66,8 @@ impl ItemRefusal { Self::BankUnavailable => "item:bank_unavailable", Self::ItemNotUsable => "item:item_not_usable", Self::NotRightNow => "item:not_right_now", + Self::Indestructible => "item:indestructible", + Self::BagNotEmpty => "item:bag_not_empty", Self::Internal => "item:internal", } } diff --git a/crates/lyracore-shared/src/lib.rs b/crates/lyracore-shared/src/lib.rs index 72a2221b4..7e9e9de00 100644 --- a/crates/lyracore-shared/src/lib.rs +++ b/crates/lyracore-shared/src/lib.rs @@ -53,3 +53,6 @@ pub const ERR_ATTACK_TARGET_DEAD: &str = "target is dead"; pub const ERR_ATTACK_FRIENDLY: &str = "target is friendly"; pub mod item_property; + +pub mod spell; +pub mod talent; diff --git a/crates/lyracore-shared/src/spell.rs b/crates/lyracore-shared/src/spell.rs new file mode 100644 index 000000000..f8bee0f7b --- /dev/null +++ b/crates/lyracore-shared/src/spell.rs @@ -0,0 +1,8 @@ +//! Spell channel classification shared by gameplay and its protocol projection. + +pub const SPELL_ATTR_CHANNELED: u32 = 0x0080; + +/// Periodic triggers retain their legacy channel behavior when the header has no flag. +pub fn is_channel_aura(effect_kind: u8, cast_flags: u32) -> bool { + effect_kind == 0x93 || cast_flags & SPELL_ATTR_CHANNELED != 0 +} diff --git a/crates/lyracore-shared/src/talent.rs b/crates/lyracore-shared/src/talent.rs new file mode 100644 index 000000000..8d03d930e --- /dev/null +++ b/crates/lyracore-shared/src/talent.rs @@ -0,0 +1,9 @@ +//! Talent reset pricing shared by the Module and the confirmation dialog. + +pub fn respec_cost_copper(respec_count: u32) -> u32 { + match respec_count.min(10) { + 0 => 10_000, + 1 => 50_000, + n => 100_000 + 50_000 * (n - 2), + } +} diff --git a/docs/benilla-compatibility.md b/docs/benilla-compatibility.md new file mode 100644 index 000000000..47785ecc3 --- /dev/null +++ b/docs/benilla-compatibility.md @@ -0,0 +1,254 @@ +# Benilla protocol compatibility + +Benilla is an independent client codec for testing LyraCore's 1.12.1 protocol. +The Gateway's development dependencies pin `benilla-protocol` and `benilla-srp` +to `c2fc3cf304075a0fa1c9accffaf5efb99be9edfe`. Tests use that revision from Cargo, +so changes in a neighboring Benilla checkout cannot change the results. + +All 277 requests in the pinned Benilla writer now have a typed dispatch pattern +or a reviewed raw route. The inventory test flags requests with neither source +classification. This accounts for requests; it does not certify every gameplay path or every outgoing +packet. Unsupported operations return a Refusal where the protocol defines one, +or a System Message naming the unavailable feature. + +The first Headless Client scenario covers SRP logon, realm discovery, encrypted +world authentication, Character selection, world entry, the Realm Clock, ping, +logout and another world entry. It repeats on a new connection with the saved +logon key. It uses the real Gateway dispatch with a Fake at the LogonStore and +WorldStore Seams. It needs no running realm, client assets or graphics runtime. + +Run the scenario and movement checks with: + +```sh +cargo test --locked -p lyracore-gateway --bin lyracore-gateway benilla_ +``` + +## What the tests establish + +The Headless Client uses Benilla for SRP proofs, header encryption, request bodies +and reply decoding. Local framing keeps the existing socket deadlines in force. +World entry checks the Character guid, name, map, position, health and level. +Clock replies must contain current Unix seconds, and pong must echo the sequence +number. Logout must complete before the next Character selection. + +Every received world packet must decode without unread bytes. Two world-entry +messages have no Benilla decoder at this pin. The test checks their lengths +explicitly: `SMSG_ACCOUNT_DATA_TIMES` at `0x0209` is 128 bytes, and +`SMSG_SET_REST_START` at `0x021E` is four bytes. Their contents are not verified. +Any other unmodelled packet fails the test. + +Two Headless Clients also move through real dispatch and a shared Fake WorldStore. +Each receives the other's ground, jump, swim and transport movement through the +production motion callback, AOI audience, known-object Gate and encrypted writer. +The Fake supplies the initial peer snapshot and applies movement to its stored +positions. The scenario checks those positions, suppresses echoes to the mover, +and excludes an observer outside the AOI. It does not run the Module's movement +reducer or verify initial peer discovery. + +Separate codec checks cover movement tails and malformed bodies. Every truncated +prefix and a body with trailing bytes must fail decoding. + +## Generate the opcode inventory + +```sh +cargo fetch --locked +cargo run --locked --quiet -p lyracore-gateway --example benilla_inventory \ + > /tmp/benilla-inventory.json +``` + +The inventory groups world messages by numeric opcode and direction. It reads +the pinned Benilla writer, movement selectors and decoder, then matches those +numbers against LyraCore's codec names and Gateway source. For example, +Benilla's `CMSG_MEETINGSTONE_STATUS_QUERY` and LyraCore's +`CMSG_MEETINGSTONE_INFO` both mean `0x0296`. + +Each row contains source locations and one of these observations: + +- `dispatch_pattern_present`: Gateway source contains a typed dispatch pattern. +- `explicitly_ignored`: a matching dispatch arm has an empty body. +- `raw_route_review_required`: the opcode has a known raw routing path. +- `no_dispatch_pattern_found`: the scanner found no typed dispatch pattern. +- `source_evidence_only`: an outgoing message appears in source. Its decoder + presence and Gateway references are separate fields. + +These are source observations, not claims that a feature works. The scanner +excludes imports, comments, strings, test code and generated bindings. It handles +`match`, destructuring patterns and `matches!`; it does not expand other macros, +resolve aliases or trace calls. Raw numeric sends need manual inspection. +Outgoing rows include Benilla decoder references and LyraCore's named `SMSG_` +references. A Benilla decoder does not make a message mandatory for every realm. +Logon packets use a separate protocol and are covered by the scenario instead. + +The inventory scanner tests run in `cargo test -p lyracore-gateway`. + +## Transport movement + +The build-5875 movement codec uses transport flag `0x02000000`, a full 64-bit +transport guid, a relative position and orientation, with no transport timestamp. +The Gateway decodes relayed `MSG_MOVE_*` bodies before the general typed reader +and writes that same vanilla layout into the movement row. Observer replies +preserve the stored body. The shared fall-damage parser recognizes the same flag +and excludes transport movement from fall damage. + +`wow_world_messages` 0.3.0 uses a later transport layout. Its typed movement values +remain the internal carrier, while the Gateway owns the transport bytes. Benilla +checks transport, jump and swim tails together. The transport regression test is +enabled in the normal suite. + +The real Module scenario also checks late-observer transport attachment. Speed, +root, hover, feather-fall, water-walk, knockback, teleport and spline completion +acknowledgements keep the World Session usable. This does not establish boat +simulation, transport membership or acknowledgement counter enforcement. + +## Gameplay scenarios and remaining gaps + +The Headless Client scenarios cover these exchanges through the same WorldStore Seam: + +- Select a target, start melee and stop melee. Check the selected target and + engagement in the Fake, plus the attack-start and attack-stop replies. +- Open a corpse, take copper and an item stack, close loot and move the item to + another inventory slot. Check the Fake's balances and inventory, then log in + again and decode the acquired item's entry, owner and stack count before the + Character's inventory pointers arrive. +- Read and accept a quest without objectives, request its reward screen, then + collect the reward. Check the quest log and copper balance, plus the completion + packet's quest id, money and XP. An incomplete quest must stay on the progress + screen and grant nothing when its reward screen is requested. + +The reward scenario exposed `CMSG_QUESTGIVER_REQUEST_REWARD`, which now uses the +same durable completion evaluation as `CMSG_QUESTGIVER_COMPLETE_QUEST`. Choosing +a reward still crosses the Store Seam and leaves the grant rules in the Module. + +## Durable scenarios + +The ignored `benilla_durable_` tests start private SpacetimeDB instances on loopback +ports. Each publishes the Module with `debug_reducers`, creates a Coordinator and +runs the real Gateway dispatch. Fixture changes apply only to these disposable +instances. No existing realm is needed or changed. + +```sh +cargo test --locked -p lyracore-gateway --bin lyracore-gateway benilla_durable_ \ + -- --ignored --test-threads=1 +``` + +They require SpacetimeDB 2.7.1 and the Wasm toolchain. The Module durable workflow +runs them in CI. They cover: + +- Two Characters discovering one another, ground and transport movement, late + observer attachment, leaving the AOI and returning. +- Accepting a two-kill quest, melee damage, kill credit, corpse money and item + loot, reward collection and persistence after reconnect. +- Splitting stacks, refusing invalid counts and equipment destinations, then + reconnecting with both stacks intact. +- Partial and full item destruction, indestructible items and persistence. +- Destroying an equipped weapon and updating the Character sheet to unarmed damage. +- Splitting into an equipped bag, capacity checks, reconnecting with its contents, + moving, equipping, using and destroying contained items. Nonempty bag moves and + destruction are refused, as are reverse swaps that would equip food. +- Starting a spell channel and cancelling it with `CMSG_CANCEL_CHANNELLING`. +- Refusing a distant, dead or wrong-kind innkeeper, then binding at the selected + innkeeper and receiving the new hearth point during the same World Session. + +Module inventory regressions also check that split, destroy, use, move, merge, +vendor sale, disenchanting and reagent consumption cannot change offered trade +items. Quest item exchanges use the same inventory Gate. Scheduled crafting checks +all reagents before consuming any or charging power. Automatic grants that grow +an offered stack clear both acceptances and refresh both trade windows. Failed +crafts also refresh the restored quantities. A completed trade delivers the newly +accepted stack count and copper. Equipment moves refuse a two-handed weapon +while an offhand item is equipped, and refuse the reverse combination. The +Character must remove the conflicting item first. + +The XP scenario found a codec mismatch. Vanilla kill XP includes base XP and the +group multiplier; other XP awards have no extra fields. The Gateway now writes +that layout directly. `XpEvent.rested_bonus` is an appended field with a zero +default so the kill packet can distinguish base XP from rested XP. + +The bag scenario found missing container pointers at world entry. Those pointers +now follow the item and Character creation packets, using the same slot mapping +as incremental inventory updates. + +## Additional protocol corrections + +The Gateway owns these build-5875 layouts where the protocol library differs: + +| Request | Benilla layout | +| --- | --- | +| `CMSG_BATTLEFIELD_JOIN` | Map, instance and group flag, nine bytes | +| `CMSG_PAGE_TEXT_QUERY` | Page id and object guid, twelve bytes | +| `CMSG_SET_FACTION_ATWAR` | Reputation-list index and boolean, five bytes | +| `CMSG_SET_FACTION_INACTIVE` | Reputation-list index and boolean, five bytes | +| `CMSG_SET_WATCHED_FACTION` | Signed reputation-list index, four bytes | +| `CMSG_ACTIVATETAXIEXPRESS` | Flightmaster, total cost, node count and node ids | +| `CMSG_OPENING_CINEMATIC` | Empty body, absent from the library's client enum | +| `CMSG_SET_LOOKING_FOR_GROUP` | Three slots and a terminated comment, absent from the library's client enum | +| `CMSG_GMTICKET_CREATE` | Category, map, position and two terminated strings; Benilla omits the optional harassment transcript | +| `CMSG_GMTICKET_UPDATETEXT` | Category and terminated text, including the Help window's 500-character limit | + +Ticket requests receive Refusals after framing checks. The unavailable service +does not decompress the optional transcript or allocate from its size headers. + +At War changes reach the existing Module operation. Bind and talent-reset +confirmations also reach existing Module operations. Selecting the gossip option +opens the confirmation dialog first. Talent quotes use the same price calculation +as the Module. Changed bind points reach the client from the Character row update. +The selected innkeeper guid reaches the Module's interaction Gate. +`CMSG_PLAYER_LOGOUT` uses the existing logout Gate, and +`CMSG_LOGOUT_CANCEL` returns its acknowledgement. Logout remains immediate when +allowed; there is no twenty-second pending logout to cancel. + +## Missing gameplay and deliberate responses + +These features remain unavailable. The responses below apply to valid requests +while in world. A decoded request or a refusal is not a working gameplay system. +Automatic stand-state, action-bar and tutorial requests announce each missing +feature once per World Session. Repeated requests do not add chat lines. + +| Feature | Protocol behavior | Missing work | +| --- | --- | --- | +| Battlegrounds | Empty instance lists, three clear queue slots, empty scoreboards and positions. Join and port requests report unavailability. | Queues, matchmaking, instances, matches, rewards and battlemaster map selection. | +| Battleground spirit healers | System Message reporting unavailability. | Resurrection queues and timers. | +| GM tickets | System disabled, no current ticket, creation and update refused. Deletion reports the already-absent ticket. | Durable tickets and GM workflow. | +| Pet stables | Stable failure code 6 for listing, storing, retrieving, swapping and slot purchase. | Stable storage and purchases. | +| Pet controls | Specific System Messages for bar changes, autocast, aura cancellation, stop-attack, naming, abandonment and unlearning. | These operations and their durable state. Existing pet action commands and progression remain separate. | +| Item containers and gifts | Inventory Refusal 39. | Item-container loot, wrapping and unwrapping. | +| Readable pages | A terminal page names the unavailable text service. | Page-text catalog import and page chains. | +| Explicit vendor destination | Inventory Refusal 39. | An atomic purchase into the requested bag and slot. Ordinary vendor purchase uses the existing path. | +| Automatic bag placement | Equipment-to-backpack uses the existing path; other requests receive Inventory Refusal 39. | Automatic placement into a chosen bag and movement of nonempty equipped bags. Explicit supported slot moves work. | +| Character presentation | Specific System Messages. | Stand state, mount flourish, helm and cloak visibility preferences. | +| Tutorials and display preferences | Specific System Messages. | Durable tutorial flags, action bar visibility, inactive and watched factions. Action buttons and At War use existing operations. | +| Ammunition and skill unlearning | Specific System Messages. | Ammunition selection and removal of learned skills. | +| Taxi chains | Activation failure code 1. | Chained flight state and charging. Single-hop taxis use the existing path. | +| Quest-share confirmations and results | System Messages. | Recipient acceptance and result relay. The existing share-offer path alone does not complete the exchange. | +| Instance resets and raid lockout queries | System Messages. | Client routing and projection across Instance Pools. Existing instance bindings are not reported as an empty list. | +| Honor and PvP flag toggles | System Messages. | Honor accounting, inspection and PvP flag timers. | +| Summon acceptance, remote cameras and opening cinematics | System Messages. | Summon offers, camera control and cinematic start state. | +| LFG preferences | System Message. | Durable LFG slots and comments. Meeting Stone requests use their existing path. | + +Movement-control acknowledgements, skipped time, inactive-mover movement, +cinematic camera advances and cinematic completion are receipt-only. They do +not move the Character or establish acknowledgement counter enforcement. Setting +the active mover to the current Character is accepted; another mover receives a +System Message. Possession and remote movement authority remain unavailable. + +The scenarios establish the behavior listed above, not every branch of the older +handlers. Further gameplay verification includes targeted item spells, channel +refresh and reconnect, pet spell execution, multi-shard instance flows and the +complete outgoing opcode inventory. The source report contains 346 outgoing +opcode rows; decoder presence alone does not require a realm to emit each one. + +## Verification record + +The workspace run passed 4,376 tests with `lyracore-package-delta` excluded, +using the Module's `debug_reducers` feature. All eight durable Benilla scenarios +passed against disposable SpacetimeDB instances. Formatting, workspace Clippy with +all targets and features, and the check of all 1,182 Gateway binding files passed. +Plain publish from `main` at `f09dafbd` to this Module migrated a private instance +without clearing it. Its Character rows survived, and `XpEvent.rested_bonus` was +readable after the update. + +An earlier full workspace run found two unchanged package-delta fixture failures: +`a_package_may_insert_a_quest_inside_the_package_range` and +`an_incomplete_quest_insert_is_refused`. Their shared `WHOLE_QUEST_ROW` lacks the +`quest_type` field required by the existing package-delta schema. Those files are +outside this change. diff --git a/gateway/Cargo.toml b/gateway/Cargo.toml index 8b99ae387..6a771e8ef 100644 --- a/gateway/Cargo.toml +++ b/gateway/Cargo.toml @@ -16,6 +16,10 @@ description = "LyraCore edge/protocol tier: terminates the WoW client protocol, name = "lyracore-gateway" path = "src/main.rs" +[[example]] +name = "benilla_inventory" +test = true + [dependencies] lyracore-shared.workspace = true @@ -51,8 +55,11 @@ dhat = { version = "0.3", optional = true } [dev-dependencies] lyracore-test-support.workspace = true +benilla-protocol = { git = "https://github.com/samwhosung/benilla.git", rev = "c2fc3cf304075a0fa1c9accffaf5efb99be9edfe" } +benilla-srp = { git = "https://github.com/samwhosung/benilla.git", rev = "c2fc3cf304075a0fa1c9accffaf5efb99be9edfe" } blake3 = "1" serde_json = "1" +syn = { version = "2", features = ["full", "visit"] } # Work-item 220 (binding schema-parity test, gateway/tests/schema_parity.rs): the module crate # compiles natively (its own test suite proves it — no wasm-only dep), so a dev-only dependency # lets the test compare module `#[table]` structs against the hand-maintained gateway bindings diff --git a/gateway/examples/benilla_inventory.rs b/gateway/examples/benilla_inventory.rs new file mode 100644 index 000000000..515c2f0b5 --- /dev/null +++ b/gateway/examples/benilla_inventory.rs @@ -0,0 +1,493 @@ +//! Inventory source evidence by numeric world opcode and direction. This does not certify behavior. + +use anyhow::{bail, Context, Result}; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use syn::visit::{self, Visit}; + +type References = BTreeMap>; + +#[derive(Default)] +struct SourceScan { + references: References, + dispatch: References, + ignored: BTreeSet, + client_variants: BTreeSet, + file: String, + function: String, +} + +fn test_only(attributes: &[syn::Attribute]) -> bool { + attributes.iter().any(|attribute| { + if attribute.path().is_ident("test") { + return true; + } + let syn::Meta::List(meta) = &attribute.meta else { + return false; + }; + if !meta.path.is_ident("cfg") { + return false; + } + let cfg = meta.tokens.to_string(); + cfg == "test" || cfg.starts_with("all (test ,") || cfg.starts_with("all (test,") + }) +} + +impl SourceScan { + fn record_pattern(&mut self, pat: &syn::Pat, ignored: bool) { + let mut pattern = SourceScan::default(); + pattern.visit_pat(pat); + for name in pattern.client_variants { + let location = self.location(); + self.dispatch + .entry(name.clone()) + .or_default() + .insert(location); + if ignored { + self.ignored.insert(name); + } + } + } + + fn location(&self) -> String { + format!("{}::{}", self.file, self.function) + } + + fn scan(&mut self, path: &Path, root: &Path) -> Result<()> { + self.file = path.strip_prefix(root)?.to_string_lossy().into_owned(); + let source = std::fs::read_to_string(path)?; + let syntax = + syn::parse_file(&source).with_context(|| format!("parse {}", path.display()))?; + self.visit_file(&syntax); + Ok(()) + } +} + +impl<'ast> Visit<'ast> for SourceScan { + fn visit_item_use(&mut self, _: &'ast syn::ItemUse) {} + fn visit_attribute(&mut self, _: &'ast syn::Attribute) {} + fn visit_item_const(&mut self, _: &'ast syn::ItemConst) {} + + fn visit_macro(&mut self, item: &'ast syn::Macro) { + if !item.path.is_ident("matches") { + return; + } + // Benilla lists movement relay opcodes in a matches! pattern. + let parse = |input: syn::parse::ParseStream<'_>| { + let value: syn::Expr = input.parse()?; + input.parse::()?; + let pattern = syn::Pat::parse_multi_with_leading_vert(input)?; + let guard = if input.peek(syn::Token![if]) { + input.parse::()?; + Some(input.parse::()?) + } else { + None + }; + if input.peek(syn::Token![,]) { + input.parse::()?; + } + Ok((value, pattern, guard)) + }; + if let Ok((value, pattern, guard)) = syn::parse::Parser::parse2(parse, item.tokens.clone()) + { + self.visit_expr(&value); + self.record_pattern(&pattern, false); + self.visit_pat(&pattern); + if let Some(guard) = guard { + self.visit_expr(&guard); + } + } + } + + fn visit_item_mod(&mut self, item: &'ast syn::ItemMod) { + if !test_only(&item.attrs) { + visit::visit_item_mod(self, item); + } + } + + fn visit_item_fn(&mut self, item: &'ast syn::ItemFn) { + if !test_only(&item.attrs) { + let previous = std::mem::replace(&mut self.function, item.sig.ident.to_string()); + self.visit_block(&item.block); + self.function = previous; + } + } + + fn visit_item_impl(&mut self, item: &'ast syn::ItemImpl) { + if !test_only(&item.attrs) { + visit::visit_item_impl(self, item); + } + } + + fn visit_impl_item_fn(&mut self, item: &'ast syn::ImplItemFn) { + if !test_only(&item.attrs) { + let previous = std::mem::replace(&mut self.function, item.sig.ident.to_string()); + self.visit_block(&item.block); + self.function = previous; + } + } + + fn visit_path(&mut self, path: &'ast syn::Path) { + let client_variant = path + .segments + .iter() + .any(|segment| segment.ident == "ClientOpcodeMessage"); + for segment in &path.segments { + let name = segment.ident.to_string(); + if ["CMSG_", "SMSG_", "MSG_"] + .iter() + .any(|prefix| name.starts_with(prefix)) + { + let location = self.location(); + self.references + .entry(name.clone()) + .or_default() + .insert(location); + if client_variant { + self.client_variants.insert(name); + } + } + } + visit::visit_path(self, path); + } + + fn visit_arm(&mut self, arm: &'ast syn::Arm) { + let ignored = + matches!(arm.body.as_ref(), syn::Expr::Block(block) if block.block.stmts.is_empty()); + self.record_pattern(&arm.pat, ignored); + visit::visit_arm(self, arm); + } + + fn visit_local(&mut self, local: &'ast syn::Local) { + self.record_pattern(&local.pat, false); + visit::visit_local(self, local); + } + + fn visit_expr_let(&mut self, expr: &'ast syn::ExprLet) { + self.record_pattern(&expr.pat, false); + visit::visit_expr_let(self, expr); + } +} + +fn rust_files(root: &Path) -> Result> { + let mut files = Vec::new(); + for entry in std::fs::read_dir(root)? { + let path = entry?.path(); + let name = path.file_name().unwrap_or_default().to_string_lossy(); + if name.contains("test") || name == "bindings" { + continue; + } + if path.is_dir() { + files.extend(rust_files(&path)?); + } else if path.extension().is_some_and(|extension| extension == "rs") { + files.push(path); + } + } + files.sort(); + Ok(files) +} + +fn metadata(root: &Path) -> Result { + let output = Command::new("cargo") + .args(["metadata", "--locked", "--offline", "--format-version", "1"]) + .current_dir(root) + .output()?; + if !output.status.success() { + bail!( + "cargo metadata: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + Ok(serde_json::from_slice(&output.stdout)?) +} + +fn benilla_package(metadata: &Value) -> Result<&Value> { + metadata["packages"] + .as_array() + .context("Cargo package list")? + .iter() + .find(|package| package["name"] == "benilla-protocol") + .context("benilla-protocol must be resolved in Cargo.lock") +} + +fn benilla_opcodes(root: &Path) -> Result> { + let source = std::fs::read_to_string(root.join("src/messages/opcode.rs"))?; + let mut names = BTreeMap::new(); + for item in syn::parse_file(&source)?.items { + if let syn::Item::Const(item) = item { + if let syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Int(value), + .. + }) = *item.expr + { + names.insert(item.ident.to_string(), value.base10_parse()?); + } + } + } + Ok(names) +} + +fn row( + direction: &str, + opcode: u16, + names: &[String], + benilla: &SourceScan, + lyra: &SourceScan, +) -> Value { + let lyra_name = wow_world_messages::vanilla::opcode_to_name(u32::from(opcode)); + let mut evidence = BTreeSet::new(); + for name in names { + evidence.extend(benilla.references.get(name).into_iter().flatten().cloned()); + } + let dispatch = lyra_name.and_then(|name| lyra.dispatch.get(name)); + let status = if direction == "gateway_to_client" { + "source_evidence_only" + } else if lyra_name.is_some_and(|name| lyra.ignored.contains(name)) { + "explicitly_ignored" + } else if dispatch.is_some() { + "dispatch_pattern_present" + } else if matches!( + opcode, + 0x005a + | 0x00f9 + | 0x0125 + | 0x01ed + | 0x0200 + | 0x0205 + | 0x0207 + | 0x023e + | 0x0258 + | 0x0312 + | 0x0317 + | 0x0318 + ) { + "raw_route_review_required" + } else { + "no_dispatch_pattern_found" + }; + json!({ + "direction": direction, + "opcode": format!("0x{opcode:04X}"), + "benilla_names": names, + "lyracore_name": lyra_name, + "benilla_evidence": evidence, + "benilla_decoder_present": if direction == "gateway_to_client" { Some(!names.is_empty()) } else { None }, + "lyracore_references": lyra_name.and_then(|name| lyra.references.get(name)), + "lyracore_dispatch": dispatch, + "handling": status, + }) +} + +fn inventory() -> Result { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .context("workspace root")?; + let metadata = metadata(root)?; + let package = benilla_package(&metadata)?; + let benilla_root = Path::new( + package["manifest_path"] + .as_str() + .context("Benilla manifest")?, + ) + .parent() + .context("Benilla crate directory")?; + let benilla_repo = benilla_root + .parent() + .and_then(Path::parent) + .context("Benilla checkout")?; + let names = benilla_opcodes(benilla_root)?; + + let mut sends = SourceScan::default(); + for path in rust_files(&benilla_root.join("src/world"))? { + sends.scan(&path, benilla_repo)?; + } + // Movement sends select their opcodes outside WorldWriter::send_movement. + let mut movement = SourceScan::default(); + movement.scan(&benilla_root.join("src/messages/movement.rs"), benilla_repo)?; + movement.scan( + &benilla_repo.join("crates/benilla-app/src/net.rs"), + benilla_repo, + )?; + for (name, locations) in movement.references { + let selectors: BTreeSet<_> = locations + .into_iter() + .filter(|location| location.ends_with("::ack_opcode") || location.ends_with("::opcode")) + .collect(); + if !selectors.is_empty() { + sends.references.entry(name).or_default().extend(selectors); + } + } + let mut receives = SourceScan::default(); + receives.scan(&benilla_root.join("src/messages/parse.rs"), benilla_repo)?; + let mut lyra = SourceScan::default(); + for path in rust_files(&root.join("gateway/src"))? { + lyra.scan(&path, root)?; + } + + let mut rows = Vec::new(); + for (direction, source) in [ + ("client_to_gateway", &sends), + ("gateway_to_client", &receives), + ] { + let mut by_number: BTreeMap> = BTreeMap::new(); + for name in source.references.keys() { + if (direction == "client_to_gateway" && name.starts_with("SMSG_")) + || (direction == "gateway_to_client" && name.starts_with("CMSG_")) + { + continue; + } + if let Some(&number) = names.get(name) { + by_number.entry(number).or_default().push(name.clone()); + } + } + if direction == "gateway_to_client" { + for number in 0..=u16::MAX { + if let Some(name) = wow_world_messages::vanilla::opcode_to_name(u32::from(number)) { + if name.starts_with("SMSG_") && lyra.references.contains_key(name) { + by_number.entry(number).or_default(); + } + } + } + } + for (number, names) in by_number { + rows.push(row(direction, number, &names, source, &lyra)); + } + } + Ok(json!({ + "benilla_source": package["source"], + "scope": "World messages. Source evidence is not scenario verification. Raw numeric sends and macros other than matches! require manual inspection. No decoder reference is itself a requirement to emit a message.", + "messages": rows, + })) +} + +fn main() -> Result<()> { + println!("{}", serde_json::to_string_pretty(&inventory()?)?); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pinned_benilla_requests_have_a_dispatch_pattern_or_known_raw_route() { + let inventory = inventory().unwrap(); + let missing: Vec<_> = inventory["messages"] + .as_array() + .unwrap() + .iter() + .filter(|row| row["handling"] == "no_dispatch_pattern_found") + .map(|row| (&row["opcode"], &row["benilla_names"])) + .collect(); + assert!( + missing.is_empty(), + "requests need protocol review: {missing:?}" + ); + } + + #[test] + fn inventory_finds_movement_relay_selector_patterns() { + let syntax = syn::parse_file( + r#" + const fn is_movement_relay(opcode: u16) -> bool { + matches!(opcode, opcode::MSG_MOVE_START_FORWARD | opcode::MSG_MOVE_HEARTBEAT) + } + "#, + ) + .unwrap(); + let mut scan = SourceScan::default(); + scan.visit_file(&syntax); + assert_eq!( + scan.references + .keys() + .map(String::as_str) + .collect::>(), + ["MSG_MOVE_HEARTBEAT", "MSG_MOVE_START_FORWARD"] + ); + assert!(scan.dispatch.is_empty()); + } + + #[test] + fn inventory_excludes_imports_comments_strings_and_test_code() { + let syntax = syn::parse_file( + r#" + use codec::CMSG_IMPORT; + // CMSG_COMMENT + fn dispatch(msg: Message) { + let _ = "CMSG_STRING"; + match msg { + ClientOpcodeMessage::CMSG_PING(ping) => send(ping), + ClientOpcodeMessage::CMSG_FORCE_RUN_SPEED_CHANGE_ACK(_) => {}, + _ => {}, + } + } + #[cfg(test)] mod tests { fn test() { send(CMSG_TEST); } } + #[cfg(test)] fn helper() { send(CMSG_HELPER); } + "#, + ) + .unwrap(); + let mut scan = SourceScan::default(); + scan.visit_file(&syntax); + assert_eq!( + scan.references + .keys() + .map(String::as_str) + .collect::>(), + ["CMSG_FORCE_RUN_SPEED_CHANGE_ACK", "CMSG_PING"] + ); + assert_eq!(scan.dispatch.len(), 2); + assert_eq!( + scan.ignored, + BTreeSet::from(["CMSG_FORCE_RUN_SPEED_CHANGE_ACK".into()]) + ); + } + + #[test] + fn inventory_matches_different_names_for_the_same_numeric_opcode() { + let mut source = SourceScan::default(); + source.references.insert( + "CMSG_MEETINGSTONE_STATUS_QUERY".into(), + BTreeSet::from(["writer::meeting_stone".into()]), + ); + let mut lyra = SourceScan::default(); + lyra.dispatch.insert( + "CMSG_MEETINGSTONE_INFO".into(), + BTreeSet::from(["dispatch_meeting_stone_action".into()]), + ); + let found = row( + "client_to_gateway", + 0x0296, + &["CMSG_MEETINGSTONE_STATUS_QUERY".into()], + &source, + &lyra, + ); + assert_eq!(found["lyracore_name"], "CMSG_MEETINGSTONE_INFO"); + assert_eq!(found["handling"], "dispatch_pattern_present"); + } + + #[test] + fn inventory_finds_let_else_and_alternative_dispatch_patterns() { + let syntax = syn::parse_file(r#" + fn handle(msg: Message) { + let (ClientOpcodeMessage::CMSG_REQUEST_PARTY_MEMBER_STATS(request), Some(guid)) = + (&msg, character) else { return }; + match msg { + ClientOpcodeMessage::CMSG_BUSY_TRADE | ClientOpcodeMessage::CMSG_IGNORE_TRADE => decline(), + _ => {}, + } + } + "#).unwrap(); + let mut scan = SourceScan::default(); + scan.visit_file(&syntax); + assert_eq!( + scan.dispatch.keys().map(String::as_str).collect::>(), + [ + "CMSG_BUSY_TRADE", + "CMSG_IGNORE_TRADE", + "CMSG_REQUEST_PARTY_MEMBER_STATS" + ] + ); + } +} diff --git a/gateway/src/codec/entity.rs b/gateway/src/codec/entity.rs index 743699a27..a7ed2776a 100644 --- a/gateway/src/codec/entity.rs +++ b/gateway/src/codec/entity.rs @@ -523,10 +523,8 @@ pub fn build_create_object( // range mid-move spawns in its real state (running/strafing) rather than idle-floating (it missed // the MSG_MOVE_START sent while out of range). Only the directional + walk bits are stamped: // FORWARD|BACKWARD|STRAFE_LEFT|STRAFE_RIGHT|TURN_LEFT|TURN_RIGHT|WALK_MODE. The conditional-data - // bits (ON_TRANSPORT 0x200, JUMPING/FALLING 0x2000, SWIMMING 0x200000, SPLINE_* 0x4/0x8000000) are - // MASKED OUT — `MovementBlock_MovementFlags::new` takes their sub-struct payloads as args, and a - // set bit with `None` payload would mis-encode; those states just spawn idle (corrected by the - // peer's next packet). SELF is added only for the player's own body (peers omit it). + // bits require their optional bodies. Peer snapshots replace this simple pose with the + // stored vanilla MovementInfo when its timestamp and position still match the entity. const SIMPLE_MOVE_MASK: u32 = 0x01 | 0x02 | 0x04 | 0x08 | 0x10 | 0x20 | 0x100; // = 0x13F let running_speed = speeds::RUN * (entity.run_speed_mult_bp as f32 / 10_000.0); let mut update_flag = MovementBlock_UpdateFlag::empty() diff --git a/gateway/src/codec/item.rs b/gateway/src/codec/item.rs index 2e8aa8fb4..b6df9284d 100644 --- a/gateway/src/codec/item.rs +++ b/gateway/src/codec/item.rs @@ -1,9 +1,24 @@ //! 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}; +/// Map vanilla bag coordinates to the Module's slot address. Capacity stays a Module Gate. +pub fn inventory_slot(bag: u8, slot: u8) -> Option { + match (bag, slot) { + (255, 0..=62) => Some(slot), + (19..=22, 0..=17) => Some(120 + (bag - 19) * 18 + slot), + _ => None, + } +} + +/// Recover the bag equipment slot and position for a Module bag-content address. +pub fn bag_content_parts(slot: u8) -> Option<(u8, u8)> { + (120..192) + .contains(&slot) + .then(|| (19 + (slot - 120) / 18, (slot - 120) % 18)) +} + /// An item-template row as the gateway reads it from `game_item_template`, flattened for the /// `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. @@ -317,6 +332,16 @@ pub fn build_inventory_change_failure() -> SMSG_INVENTORY_CHANGE_FAILURE { pub fn build_inventory_refusal(refusal: ItemRefusal) -> SMSG_INVENTORY_CHANGE_FAILURE { let (item1, item2, bag_type_subclass) = (Guid::new(0), Guid::new(0), 0); match refusal { + ItemRefusal::Indestructible => SMSG_INVENTORY_CHANGE_FAILURE::CantDropSoulbound { + item1, + item2, + bag_type_subclass, + }, + ItemRefusal::BagNotEmpty => SMSG_INVENTORY_CHANGE_FAILURE::CanOnlyDoWithEmptyBags { + item1, + item2, + bag_type_subclass, + }, ItemRefusal::ItemNotFound => SMSG_INVENTORY_CHANGE_FAILURE::ItemNotFound { item1, item2, diff --git a/gateway/src/codec/leveling.rs b/gateway/src/codec/leveling.rs index 1d3b330c7..f53cba0a4 100644 --- a/gateway/src/codec/leveling.rs +++ b/gateway/src/codec/leveling.rs @@ -18,22 +18,25 @@ pub fn build_exploration_experience_raw(area_id: u32, experience: u32) -> (u16, (SMSG_EXPLORATION_EXPERIENCE_OPCODE, body) } -pub fn build_log_xpgain(killed_guid: u64, total_exp: u32, is_kill: bool) -> SMSG_LOG_XPGAIN { - SMSG_LOG_XPGAIN { - target: Guid::new(killed_guid), - total_exp, - // Kill = "from killing X"; NonKill = the bare "You gain N experience" (exploration discovery, - // quest — no source unit). NonKill's rested-split fields are trivial here (no rested on these - // sources): the whole amount is un-rested, no group bonus. - exp_type: if is_kill { - SMSG_LOG_XPGAIN_ExperienceAwardType::Kill - } else { - SMSG_LOG_XPGAIN_ExperienceAwardType::NonKill { - exp_group_bonus: 0.0, - experience_without_rested: total_exp, - } - }, +/// Build 5875 XP feedback. Only a kill carries base XP and the group multiplier. +/// The typed dependency attaches that tail to the opposite award type. +pub const SMSG_LOG_XPGAIN: u16 = 0x01d0; + +pub fn build_log_xpgain_raw( + killed_guid: u64, + total_exp: u32, + is_kill: bool, + rested_bonus: u32, +) -> (u16, Vec) { + let mut body = Vec::with_capacity(if is_kill { 21 } else { 13 }); + body.extend_from_slice(&killed_guid.to_le_bytes()); + body.extend_from_slice(&total_exp.to_le_bytes()); + body.push(u8::from(!is_kill)); + if is_kill { + body.extend_from_slice(&total_exp.saturating_sub(rested_bonus).to_le_bytes()); + body.extend_from_slice(&1.0f32.to_le_bytes()); } + (SMSG_LOG_XPGAIN, body) } /// Build `SMSG_LEVELUP_INFO` (Tier 5 / XP) — the "You have reached level N" ding popup, carrying the @@ -71,11 +74,27 @@ mod tests { use super::*; #[test] - fn log_xpgain_carries_the_kill_and_total_exp() { - let msg = build_log_xpgain(0xF130_0000_0000_0001, 45, true); - assert_eq!(msg.target.guid(), 0xF130_0000_0000_0001); - assert_eq!(msg.total_exp, 45); - assert_eq!(msg.exp_type, SMSG_LOG_XPGAIN_ExperienceAwardType::Kill); + fn benilla_decodes_kill_rested_and_nonkill_xp_without_a_tail() { + use benilla_protocol::messages::{parse_server_with_tail, ServerPacket}; + for (guid, total, kill, rested, base) in [ + (0xF130_0000_0000_0001, 45, true, 0, 45), + (0xF130_0000_0000_0001, 90, true, 45, 45), + (0, 90, false, 0, 90), + ] { + let (opcode, body) = build_log_xpgain_raw(guid, total, kill, rested); + let (ServerPacket::XpGain(xp), tail) = parse_server_with_tail(opcode, &body).unwrap() + else { + panic!("expected XP gain"); + }; + assert_eq!(tail, 0); + assert_eq!( + (xp.victim, xp.total, xp.kill, xp.base), + (guid, total, kill, base) + ); + if kill { + assert_eq!(&body[17..], &1.0f32.to_le_bytes()); + } + } } #[test] diff --git a/gateway/src/codec/mod.rs b/gateway/src/codec/mod.rs index 191e22b34..1d99fa15d 100644 --- a/gateway/src/codec/mod.rs +++ b/gateway/src/codec/mod.rs @@ -1,7 +1,7 @@ //! Codec: map gateway-side row views to `wow_world_messages` (vanilla) messages. //! -//! The byte layouts — including the update mask, packed guids, and the movement block — are -//! owned by `wow_world_messages`. This module is the thin mapping from the gateway's flattened +//! Most byte layouts come from `wow_world_messages`. The build-5875 movement codec corrects +//! its transport flag and layout. This module is the thin mapping from the gateway's flattened //! row views, such as `CharacterView`, `EntityView`, and `AuctionView`, to the crate's typed //! messages: char enum, the //! `CREATE_OBJECT2` self/peer spawn (built via the crate's `UpdatePlayer::builder()`, which @@ -37,6 +37,7 @@ mod mail; mod meeting_stone; mod member_stats; mod movement; +mod movement_info; mod npc; mod pet; mod quest; @@ -72,6 +73,7 @@ pub use mail::*; pub use meeting_stone::*; pub use member_stats::*; pub use movement::*; +pub use movement_info::*; pub use npc::*; pub use pet::*; pub use quest::*; @@ -103,7 +105,6 @@ use wow_world_messages::vanilla::{ CharacterFlags, CharacterGear, Class, - ClientMessage, CreatureFamily, DamageInfo, DateTime, @@ -132,7 +133,6 @@ use wow_world_messages::vanilla::{ LogoutSpeed, MSG_CORPSE_QUERY_Server, MSG_MOVE_FALL_LAND_Server, - MSG_MOVE_HEARTBEAT_Client, MSG_MOVE_HEARTBEAT_Server, MSG_MOVE_JUMP_Server, MSG_MOVE_SET_FACING_Server, @@ -179,7 +179,6 @@ use wow_world_messages::vanilla::{ SMSG_CREATURE_QUERY_RESPONSE_found, SMSG_GAMEOBJECT_QUERY_RESPONSE_found, SMSG_ITEM_QUERY_SINGLE_RESPONSE_found, - SMSG_LOG_XPGAIN_ExperienceAwardType, SMSG_MONSTER_MOVE_MonsterMoveType, SMSG_PET_SPELLS_action_bars, SMSG_SPELL_GO_CastFlags, @@ -253,7 +252,6 @@ use wow_world_messages::vanilla::{ SMSG_LOGIN_SETTIMESPEED, SMSG_LOGIN_VERIFY_WORLD, SMSG_LOGOUT_RESPONSE, - SMSG_LOG_XPGAIN, SMSG_LOOT_MASTER_LIST, SMSG_LOOT_MONEY_NOTIFY, SMSG_LOOT_RELEASE_RESPONSE, diff --git a/gateway/src/codec/movement.rs b/gateway/src/codec/movement.rs index 290be3888..e5025773d 100644 --- a/gateway/src/codec/movement.rs +++ b/gateway/src/codec/movement.rs @@ -26,17 +26,8 @@ pub fn relayed_move_opcode(msg: &ClientOpcodeMessage) -> Option { }) } -/// 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`). -/// -/// **No longer on the hot path**: the peer relay ships -/// [`build_movement_relay_raw`]'s pre-serialized body instead of paying a decode + re-encode per -/// observer. This stays as the typed REFERENCE implementation the raw builder is pinned against — -/// `codec::tests::raw_movement_relay_is_byte_identical_to_the_typed_path` serializes it and demands -/// the raw bytes match, which is the only thing standing between a memcpy relay and a silent -/// peer-position desync. Deleting it would delete that proof, so it is deliberately kept. +/// Typed reference for movement without transport tails. Transport uses the build-5875 +/// codec and the raw relay, since the library's transport layout belongs to later clients. #[cfg_attr(not(test), allow(dead_code))] pub fn build_movement_relay( opcode: u32, @@ -135,28 +126,15 @@ pub fn build_movement_relay( } /// The shortest legal `MovementInfo` body: `flags u32 + timestamp u32 + position 12 + -/// orientation f32 + fall_time f32`. Every optional block (transport / pitch / jump / spline +/// orientation f32 + fall_time u32`. Every optional block (transport / pitch / jump / spline /// elevation) only makes it longer, so this is a floor, not an exact size — it exists so a truncated /// row can never be framed as a movement packet (the client would parse past the end of the body and /// desync its stream). const MIN_MOVEMENT_INFO_LEN: usize = 4 + 4 + 12 + 4 + 4; -/// The RAW twin of [`build_movement_relay`]: the same `MSG_MOVE_*` packet, built as -/// `(opcode, body)` for [`Outbound::Raw`](crate::world::Outbound::Raw) **without decoding -/// `info_bytes`**. `game_entity_motion.movement_info` already holds exactly the bytes gtker would -/// serialize a `MovementInfo` into (the gateway put them there via [`movement_info_to_bytes`]), and -/// the server-side body is `PackedGuid guid` followed by that block verbatim — so the relay is a -/// packed guid plus a memcpy, not a decode and a re-encode. -/// -/// Why it matters: this is called once per OBSERVER, per mover, per heartbeat. At 371 co-located -/// clients that is ~275 k/s, and the typed path paid a full parse + a fresh `ServerOpcodeMessage` -/// for each one to produce an identical ~30-byte body. Only the header differs per observer, and the -/// writer already frames and encrypts that per session. -/// -/// Returns `None` for a non-relayed opcode (matching `build_movement_relay`'s `None`, pinned -/// exhaustively by `codec::tests`) or for an `info_bytes` too short to be a `MovementInfo`. -/// **The body is byte-identical to what `build_movement_relay` serializes** — that is the whole -/// premise, and it is asserted, not assumed, in `raw_movement_relay_is_byte_identical_to_the_typed_path`. +/// Relay the stored build-5875 movement body under its original opcode, prefixed by the +/// mover's packed guid. Each observer's writer supplies the frame header and encryption. +/// Returns None for an unsupported opcode or a body shorter than the fixed movement fields. pub fn build_movement_relay_raw( opcode: u32, mover_guid: u64, @@ -394,48 +372,6 @@ pub fn build_monster_move_facing( } } -/// Carry a `MovementInfo` across the DB as opaque bytes (`game_movement_event.movement_info`). -/// The crate's `MovementInfo` (de)serializer is `pub(crate)`, but `MSG_MOVE_HEARTBEAT_Client`'s -/// whole body *is* exactly one `MovementInfo` and its codec is public — so we round-trip through -/// it. The vanilla client header is 6 bytes: `[(body_len+4) as u16 BE][opcode as u32 LE]`. -const MOVE_HEADER_LEN: usize = 6; - -/// Serialize a `MovementInfo` to its raw body bytes (what travels in the event row). -pub fn movement_info_to_bytes(info: &MovementInfo) -> Result> { - let mut buf = Vec::new(); - MSG_MOVE_HEARTBEAT_Client { info: info.clone() }.write_unencrypted_client(&mut buf)?; - if buf.len() < MOVE_HEADER_LEN { - return Err(anyhow!( - "movement body shorter than header ({} bytes)", - buf.len() - )); - } - Ok(buf.split_off(MOVE_HEADER_LEN)) -} - -/// Reconstruct a `MovementInfo` from raw body bytes by framing them as a `MSG_MOVE_HEARTBEAT` -/// client packet and decoding. All `MSG_MOVE_*` client bodies are an identical `MovementInfo`, so -/// HEARTBEAT works as the carrier regardless of the event's real opcode. -/// -/// The relay stopped calling this once the raw twin shipped (it memcpys the stored block instead), so its -/// only remaining callers are tests — kept as the decode half of the `movement_info_to_bytes` -/// round-trip that pins the carrier format the module's rows are written in. -#[cfg_attr(not(test), allow(dead_code))] -pub fn bytes_to_movement_info(body: &[u8]) -> Result { - let size_field = (body.len() as u16) - .checked_add(4) - .ok_or_else(|| anyhow!("movement body too large"))?; - let mut framed = Vec::with_capacity(MOVE_HEADER_LEN + body.len()); - framed.extend_from_slice(&size_field.to_be_bytes()); - framed.extend_from_slice(&(movement_opcodes::MSG_MOVE_HEARTBEAT).to_le_bytes()); - framed.extend_from_slice(body); - match ClientOpcodeMessage::read_unencrypted(&mut framed.as_slice()) { - Ok(ClientOpcodeMessage::MSG_MOVE_HEARTBEAT(c)) => Ok(c.info), - Ok(other) => Err(anyhow!("movement carrier decoded to unexpected {other}")), - Err(e) => Err(anyhow!("movement carrier decode failed: {e}")), - } -} - #[cfg(test)] mod ground_path_tests { use super::*; diff --git a/gateway/src/codec/movement_info.rs b/gateway/src/codec/movement_info.rs new file mode 100644 index 000000000..45156a158 --- /dev/null +++ b/gateway/src/codec/movement_info.rs @@ -0,0 +1,241 @@ +//! Build-5875 MovementInfo bodies. The typed codec uses the later transport flag and layout. + +use anyhow::{ensure, Result}; +use lyracore_shared::env::{MOVEMENT_FLAG_ON_TRANSPORT, MOVEMENT_FLAG_SWIMMING}; +use std::io::Read; +use wow_world_messages::vanilla::{ + MovementInfo, MovementInfo_MovementFlags, MovementInfo_MovementFlags_Jumping, + MovementInfo_MovementFlags_OnTransport, MovementInfo_MovementFlags_SplineElevation, + MovementInfo_MovementFlags_Swimming, TransportInfo, Vector3d, +}; +use wow_world_messages::Guid; + +const JUMPING: u32 = 0x2000; +const SPLINE_ELEVATION: u32 = 0x0400_0000; + +fn word(body: &mut &[u8]) -> Result<[u8; 4]> { + let mut bytes = [0; 4]; + body.read_exact(&mut bytes)?; + Ok(bytes) +} + +fn float(body: &mut &[u8]) -> Result { + Ok(f32::from_le_bytes(word(body)?)) +} + +fn position(body: &mut &[u8]) -> Result { + Ok(Vector3d { + x: float(body)?, + y: float(body)?, + z: float(body)?, + }) +} + +pub fn bytes_to_movement_info(mut body: &[u8]) -> Result { + let flags = u32::from_le_bytes(word(&mut body)?); + let timestamp = u32::from_le_bytes(word(&mut body)?); + let pos = position(&mut body)?; + let orientation = float(&mut body)?; + let transport = if flags & MOVEMENT_FLAG_ON_TRANSPORT != 0 { + let mut guid = [0; 8]; + body.read_exact(&mut guid)?; + Some(MovementInfo_MovementFlags_OnTransport { + transport: TransportInfo { + guid: Guid::new(u64::from_le_bytes(guid)), + position: position(&mut body)?, + orientation: float(&mut body)?, + timestamp: 0, // The library requires this field; vanilla has no transport time. + }, + }) + } else { + None + }; + let swimming = if flags & MOVEMENT_FLAG_SWIMMING != 0 { + Some(MovementInfo_MovementFlags_Swimming { + pitch: float(&mut body)?, + }) + } else { + None + }; + // Keep the u32 millisecond bits in the library's f32 carrier. + let fall_time = f32::from_bits(u32::from_le_bytes(word(&mut body)?)); + let jumping = if flags & JUMPING != 0 { + Some(MovementInfo_MovementFlags_Jumping { + z_speed: float(&mut body)?, + cos_angle: float(&mut body)?, + sin_angle: float(&mut body)?, + xy_speed: float(&mut body)?, + }) + } else { + None + }; + let spline = if flags & SPLINE_ELEVATION != 0 { + Some(MovementInfo_MovementFlags_SplineElevation { + spline_elevation: float(&mut body)?, + }) + } else { + None + }; + ensure!(body.is_empty(), "unread movement bytes: {}", body.len()); + Ok(MovementInfo { + flags: MovementInfo_MovementFlags::new(flags, transport, jumping, swimming, spline), + timestamp, + position: pos, + orientation, + fall_time, + }) +} + +pub fn movement_info_to_bytes(info: &MovementInfo) -> Result> { + use wow_world_messages::vanilla::{ClientMessage, MSG_MOVE_HEARTBEAT_Client}; + let mut framed = Vec::new(); + MSG_MOVE_HEARTBEAT_Client { info: info.clone() }.write_unencrypted_client(&mut framed)?; + let mut body = framed.split_off(6); + let raw_flags = u32::from_le_bytes(word(&mut body.as_slice())?); + let flags = &info.flags; + for (mask, present) in [ + ( + MOVEMENT_FLAG_ON_TRANSPORT, + flags.get_on_transport().is_some(), + ), + (MOVEMENT_FLAG_SWIMMING, flags.get_swimming().is_some()), + (JUMPING, flags.get_jumping().is_some()), + (SPLINE_ELEVATION, flags.get_spline_elevation().is_some()), + ] { + ensure!( + (raw_flags & mask != 0) == present, + "movement flag {mask:#010x} disagrees with its body" + ); + } + if let Some(tail) = flags.get_on_transport() { + // Replace the library's packed guid and timestamp with vanilla's full guid and pose. + let guid = tail.transport.guid.guid(); + let packed_len = 1 + guid.to_le_bytes().iter().filter(|&&byte| byte != 0).count(); + let mut transport = Vec::with_capacity(24); + transport.extend_from_slice(&guid.to_le_bytes()); + for value in [ + tail.transport.position.x, + tail.transport.position.y, + tail.transport.position.z, + tail.transport.orientation, + ] { + transport.extend_from_slice(&value.to_le_bytes()); + } + body.splice(24..24 + packed_len + 20, transport); + } + Ok(body) +} + +/// Replace the initial, tail-free living pose with the Module's current vanilla movement body. +/// CREATE descriptors and speeds still come from the typed builder. +pub fn create_with_movement( + create: &wow_world_messages::vanilla::SMSG_UPDATE_OBJECT, + movement: &[u8], +) -> Result> { + use wow_world_messages::vanilla::ServerMessage; + bytes_to_movement_info(movement)?; + let mut framed = Vec::new(); + create.write_unencrypted_server(&mut framed)?; + let mut body = framed.split_off(4); + ensure!( + body.len() >= 9 && body[..4] == 1u32.to_le_bytes() && body[5] == 3, + "expected one CREATE_OBJECT2" + ); + let living = 9 + body[6].count_ones() as usize; + ensure!( + body.len() >= living + 28 && body[living - 1] & 0x20 != 0, + "expected a living CREATE" + ); + let flags = u32::from_le_bytes(body[living..living + 4].try_into()?); + ensure!( + flags & !0x13f == 0, + "initial CREATE already has conditional movement" + ); + body.splice(living..living + 28, movement.iter().copied()); + Ok(body) +} + +/// Decode relayed movement before the general typed packet reader sees its transport tail. +pub fn read_movement_client( + opcode: u32, + body: &[u8], +) -> Result> { + use super::{movement_opcodes, ClientOpcodeMessage}; + use wow_world_messages::vanilla::*; + if !movement_opcodes::is_slice_move(opcode) { + return Ok(None); + } + let info = bytes_to_movement_info(body)?; + Ok(Some(match opcode { + movement_opcodes::MSG_MOVE_START_FORWARD => { + ClientOpcodeMessage::MSG_MOVE_START_FORWARD(Box::new(MSG_MOVE_START_FORWARD_Client { + info, + })) + } + movement_opcodes::MSG_MOVE_START_BACKWARD => { + ClientOpcodeMessage::MSG_MOVE_START_BACKWARD(Box::new(MSG_MOVE_START_BACKWARD_Client { + info, + })) + } + movement_opcodes::MSG_MOVE_STOP => { + ClientOpcodeMessage::MSG_MOVE_STOP(Box::new(MSG_MOVE_STOP_Client { info })) + } + movement_opcodes::MSG_MOVE_START_STRAFE_LEFT => { + ClientOpcodeMessage::MSG_MOVE_START_STRAFE_LEFT(Box::new( + MSG_MOVE_START_STRAFE_LEFT_Client { info }, + )) + } + movement_opcodes::MSG_MOVE_START_STRAFE_RIGHT => { + ClientOpcodeMessage::MSG_MOVE_START_STRAFE_RIGHT(Box::new( + MSG_MOVE_START_STRAFE_RIGHT_Client { info }, + )) + } + movement_opcodes::MSG_MOVE_STOP_STRAFE => { + ClientOpcodeMessage::MSG_MOVE_STOP_STRAFE(Box::new(MSG_MOVE_STOP_STRAFE_Client { + info, + })) + } + movement_opcodes::MSG_MOVE_JUMP => { + ClientOpcodeMessage::MSG_MOVE_JUMP(Box::new(MSG_MOVE_JUMP_Client { info })) + } + movement_opcodes::MSG_MOVE_START_TURN_LEFT => { + ClientOpcodeMessage::MSG_MOVE_START_TURN_LEFT(Box::new( + MSG_MOVE_START_TURN_LEFT_Client { info }, + )) + } + movement_opcodes::MSG_MOVE_START_TURN_RIGHT => { + ClientOpcodeMessage::MSG_MOVE_START_TURN_RIGHT(Box::new( + MSG_MOVE_START_TURN_RIGHT_Client { info }, + )) + } + movement_opcodes::MSG_MOVE_STOP_TURN => { + ClientOpcodeMessage::MSG_MOVE_STOP_TURN(Box::new(MSG_MOVE_STOP_TURN_Client { info })) + } + movement_opcodes::MSG_MOVE_SET_RUN_MODE => { + ClientOpcodeMessage::MSG_MOVE_SET_RUN_MODE(Box::new(MSG_MOVE_SET_RUN_MODE_Client { + info, + })) + } + movement_opcodes::MSG_MOVE_SET_WALK_MODE => { + ClientOpcodeMessage::MSG_MOVE_SET_WALK_MODE(Box::new(MSG_MOVE_SET_WALK_MODE_Client { + info, + })) + } + movement_opcodes::MSG_MOVE_FALL_LAND => { + ClientOpcodeMessage::MSG_MOVE_FALL_LAND(Box::new(MSG_MOVE_FALL_LAND_Client { info })) + } + movement_opcodes::MSG_MOVE_START_SWIM => { + ClientOpcodeMessage::MSG_MOVE_START_SWIM(Box::new(MSG_MOVE_START_SWIM_Client { info })) + } + movement_opcodes::MSG_MOVE_STOP_SWIM => { + ClientOpcodeMessage::MSG_MOVE_STOP_SWIM(Box::new(MSG_MOVE_STOP_SWIM_Client { info })) + } + movement_opcodes::MSG_MOVE_SET_FACING => { + ClientOpcodeMessage::MSG_MOVE_SET_FACING(Box::new(MSG_MOVE_SET_FACING_Client { info })) + } + movement_opcodes::MSG_MOVE_HEARTBEAT => { + ClientOpcodeMessage::MSG_MOVE_HEARTBEAT(Box::new(MSG_MOVE_HEARTBEAT_Client { info })) + } + _ => return Ok(None), + })) +} diff --git a/gateway/src/codec/property_tests.rs b/gateway/src/codec/property_tests.rs index 1c29be815..cc65743f0 100644 --- a/gateway/src/codec/property_tests.rs +++ b/gateway/src/codec/property_tests.rs @@ -155,26 +155,22 @@ fn the_bridge_envelope_splitter_never_panics_and_only_accepts_the_stc_shape() { // The movement-info carrier // =========================================================================================== -/// `bytes_to_movement_info` re-decodes a raw movement body by wrapping it back into a synthetic -/// frame — arithmetic on a remote-supplied length. It must return `Result` for every input. +/// Movement bodies come from clients and must fail cleanly on arbitrary bytes. #[test] fn the_movement_carrier_decoder_never_panics_on_any_byte_string() { let mut rng = Rng::new(0x4D4F_5645); for _ in 0..CASES { let len = rng.below(96); - let _ = movement::bytes_to_movement_info(&rng.bytes(len)); + let _ = super::movement_info::bytes_to_movement_info(&rng.bytes(len)); } } -/// The one arithmetic edge in that function: it prepends a 4-byte header, so a body within 4 bytes -/// of `u16::MAX` overflows the frame-size field. That must be an `Err`, not a wrap — a wrapped size -/// is the "one wrong size field desyncs every later header" failure this codebase has already paid -/// for once on the outbound side (the `SMSG_COMPRESSED_MOVES` crowd-scale corruption). +/// A movement body has a bounded set of optional fields. Excess bytes must not be ignored. #[test] -fn a_movement_body_that_would_overflow_the_frame_size_is_an_error_not_a_wrap() { +fn oversized_movement_bodies_are_refused() { for len in [65_531usize, 65_532, 65_535, 70_000] { let body = vec![0u8; len]; - let out = movement::bytes_to_movement_info(&body); + let out = super::movement_info::bytes_to_movement_info(&body); assert!( out.is_err(), "a {len}-byte movement body cannot be re-framed under a u16 size field; it must be \ diff --git a/gateway/src/codec/tests.rs b/gateway/src/codec/tests.rs index b0bed8291..1c6708ba1 100644 --- a/gateway/src/codec/tests.rs +++ b/gateway/src/codec/tests.rs @@ -5,7 +5,8 @@ use std::time::{SystemTime, UNIX_EPOCH}; // Only the loot byte-match test needs gtker's typed loot response (the runtime path is raw). use wow_world_messages::vanilla::ServerMessage; use wow_world_messages::vanilla::{ - EnvironmentalDamageType, SMSG_LOOT_RESPONSE_LootMethod, TimerType, SMSG_LOOT_RESPONSE, + EnvironmentalDamageType, MSG_MOVE_HEARTBEAT_Client, SMSG_LOOT_RESPONSE_LootMethod, TimerType, + SMSG_LOOT_RESPONSE, }; // Group loot methods: the vote-kind byte constants + wire RollVote enum. use lyracore_shared::loot_roll::vote_kind; @@ -1199,8 +1200,7 @@ fn movement_relay_roundtrips_under_same_opcode() { fn movement_info_cases() -> Vec<(&'static str, MovementInfo)> { use wow_world_messages::vanilla::{ MovementInfo_MovementFlags, MovementInfo_MovementFlags_Jumping, - MovementInfo_MovementFlags_OnTransport, MovementInfo_MovementFlags_SplineElevation, - MovementInfo_MovementFlags_Swimming, TransportInfo, + MovementInfo_MovementFlags_SplineElevation, MovementInfo_MovementFlags_Swimming, }; let base = MovementInfo { flags: MovementInfo_MovementFlags::empty(), @@ -1245,26 +1245,6 @@ fn movement_info_cases() -> Vec<(&'static str, MovementInfo)> { ..base.clone() }, ), - ( - "on a transport (a packed guid nested mid-block)", - MovementInfo { - flags: MovementInfo_MovementFlags::empty().set_on_transport( - MovementInfo_MovementFlags_OnTransport { - transport: TransportInfo { - guid: Guid::new(0x0000_1234_5678_0000), - position: Vector3d { - x: 1.0, - y: 2.0, - z: 3.0, - }, - orientation: 0.5, - timestamp: 77, - }, - }, - ), - ..base.clone() - }, - ), ( "spline elevation (a trailing 4-byte block)", MovementInfo { @@ -1298,16 +1278,8 @@ fn movement_info_cases() -> Vec<(&'static str, MovementInfo)> { ] } -/// **The peer-motion relay's load-bearing premise, asserted rather than argued.** The peer-motion relay no -/// longer decodes `game_entity_motion.movement_info` and re-encodes it through gtker; it writes a -/// packed guid and memcpys the stored block. That is only safe if the resulting `(opcode, body)` is -/// what `build_movement_relay` + gtker's own serializer produced — a wrong layout here does not -/// error anywhere, it silently desyncs every peer's position on every client. -/// -/// So: for EVERY relayed opcode × every `MovementInfo` shape × a set of guids chosen to exercise the -/// packed-guid encoding (1 byte, high bytes only, all 8 bytes, and the zero pattern), serialize the -/// typed message the old path sent, strip its 4-byte header, and demand the raw builder's opcode and -/// body match byte for byte. +/// Non-transport movement agrees with the typed codec across all supported opcodes and +/// packed mover guids. Benilla checks transport movement independently. #[test] fn raw_movement_relay_is_byte_identical_to_the_typed_path() { // 1 = one low byte; 0x0100 = one HIGH byte (a zero low byte must be skipped, not written); @@ -1349,7 +1321,7 @@ fn raw_movement_relay_is_byte_identical_to_the_typed_path() { } assert_eq!( compared, - 7 * 17 * 5, + 6 * 17 * 5, "every case × opcode × guid must have been compared" ); } diff --git a/gateway/src/stdb/bindings/aura_type.rs b/gateway/src/stdb/bindings/aura_type.rs index 6e99ce67d..412d716e5 100644 --- a/gateway/src/stdb/bindings/aura_type.rs +++ b/gateway/src/stdb/bindings/aura_type.rs @@ -124,6 +124,7 @@ impl __sdk::__query_builder::HasCols for Aura { /// /// Provides typed access to indexed columns for query building. pub struct AuraIxCols { + pub caster_guid: __sdk::__query_builder::IxCol, pub expires_at: __sdk::__query_builder::IxCol, pub id: __sdk::__query_builder::IxCol, pub next_tick_micros: __sdk::__query_builder::IxCol, @@ -134,6 +135,7 @@ impl __sdk::__query_builder::HasIxCols for Aura { type IxCols = AuraIxCols; fn ix_cols(table_name: &'static str) -> Self::IxCols { AuraIxCols { + caster_guid: __sdk::__query_builder::IxCol::new(table_name, "caster_guid"), expires_at: __sdk::__query_builder::IxCol::new(table_name, "expires_at"), id: __sdk::__query_builder::IxCol::new(table_name, "id"), next_tick_micros: __sdk::__query_builder::IxCol::new(table_name, "next_tick_micros"), diff --git a/gateway/src/stdb/bindings/gw_bind_home_reducer.rs b/gateway/src/stdb/bindings/gw_bind_home_reducer.rs index 3f96b5f7a..631cf22fa 100644 --- a/gateway/src/stdb/bindings/gw_bind_home_reducer.rs +++ b/gateway/src/stdb/bindings/gw_bind_home_reducer.rs @@ -10,12 +10,14 @@ use super::session_actor_type::SessionActor; #[sats(crate = __lib)] pub(super) struct GwBindHomeArgs { pub request_actor: SessionActor, + pub innkeeper_guid: u64, } impl From for super::Reducer { fn from(args: GwBindHomeArgs) -> Self { Self::GwBindHome { request_actor: args.request_actor, + innkeeper_guid: args.innkeeper_guid, } } } @@ -35,8 +37,8 @@ pub trait gw_bind_home { /// The reducer will run asynchronously in the future, /// and this method provides no way to listen for its completion status. /// /// Use [`gw_bind_home:gw_bind_home_then`] to run a callback after the reducer completes. - fn gw_bind_home(&self, request_actor: SessionActor) -> __sdk::Result<()> { - self.gw_bind_home_then(request_actor, |_, _| {}) + fn gw_bind_home(&self, request_actor: SessionActor, innkeeper_guid: u64) -> __sdk::Result<()> { + self.gw_bind_home_then(request_actor, innkeeper_guid, |_, _| {}) } /// Request that the remote module invoke the reducer `gw_bind_home` to run as soon as possible, @@ -48,6 +50,7 @@ pub trait gw_bind_home { fn gw_bind_home_then( &self, request_actor: SessionActor, + innkeeper_guid: u64, callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + Send @@ -59,12 +62,18 @@ impl gw_bind_home for super::RemoteReducers { fn gw_bind_home_then( &self, request_actor: SessionActor, + innkeeper_guid: u64, callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + Send + 'static, ) -> __sdk::Result<()> { - self.imp - .invoke_reducer_with_callback(GwBindHomeArgs { request_actor }, callback) + self.imp.invoke_reducer_with_callback( + GwBindHomeArgs { + request_actor, + innkeeper_guid, + }, + callback, + ) } } diff --git a/gateway/src/stdb/bindings/gw_destroy_item_reducer.rs b/gateway/src/stdb/bindings/gw_destroy_item_reducer.rs new file mode 100644 index 000000000..c89ab8a37 --- /dev/null +++ b/gateway/src/stdb/bindings/gw_destroy_item_reducer.rs @@ -0,0 +1,89 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::session_actor_type::SessionActor; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct GwDestroyItemArgs { + pub request_actor: SessionActor, + pub slot: u8, + pub count: u32, +} + +impl From for super::Reducer { + fn from(args: GwDestroyItemArgs) -> Self { + Self::GwDestroyItem { + request_actor: args.request_actor, + slot: args.slot, + count: args.count, + } + } +} + +impl __sdk::InModule for GwDestroyItemArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `gw_destroy_item`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait gw_destroy_item { + /// Request that the remote module invoke the reducer `gw_destroy_item` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`gw_destroy_item:gw_destroy_item_then`] to run a callback after the reducer completes. + fn gw_destroy_item( + &self, + request_actor: SessionActor, + slot: u8, + count: u32, + ) -> __sdk::Result<()> { + self.gw_destroy_item_then(request_actor, slot, count, |_, _| {}) + } + + /// Request that the remote module invoke the reducer `gw_destroy_item` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. + fn gw_destroy_item_then( + &self, + request_actor: SessionActor, + slot: u8, + count: u32, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl gw_destroy_item for super::RemoteReducers { + fn gw_destroy_item_then( + &self, + request_actor: SessionActor, + slot: u8, + count: u32, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp.invoke_reducer_with_callback( + GwDestroyItemArgs { + request_actor, + slot, + count, + }, + callback, + ) + } +} diff --git a/gateway/src/stdb/bindings/gw_split_item_reducer.rs b/gateway/src/stdb/bindings/gw_split_item_reducer.rs new file mode 100644 index 000000000..786aaee76 --- /dev/null +++ b/gateway/src/stdb/bindings/gw_split_item_reducer.rs @@ -0,0 +1,95 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::session_actor_type::SessionActor; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub(super) struct GwSplitItemArgs { + pub request_actor: SessionActor, + pub from_slot: u8, + pub to_slot: u8, + pub count: u32, +} + +impl From for super::Reducer { + fn from(args: GwSplitItemArgs) -> Self { + Self::GwSplitItem { + request_actor: args.request_actor, + from_slot: args.from_slot, + to_slot: args.to_slot, + count: args.count, + } + } +} + +impl __sdk::InModule for GwSplitItemArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the reducer `gw_split_item`. +/// +/// Implemented for [`super::RemoteReducers`]. +pub trait gw_split_item { + /// Request that the remote module invoke the reducer `gw_split_item` to run as soon as possible. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and this method provides no way to listen for its completion status. + /// /// Use [`gw_split_item:gw_split_item_then`] to run a callback after the reducer completes. + fn gw_split_item( + &self, + request_actor: SessionActor, + from_slot: u8, + to_slot: u8, + count: u32, + ) -> __sdk::Result<()> { + self.gw_split_item_then(request_actor, from_slot, to_slot, count, |_, _| {}) + } + + /// Request that the remote module invoke the reducer `gw_split_item` to run as soon as possible, + /// registering `callback` to run when we are notified that the reducer completed. + /// + /// This method returns immediately, and errors only if we are unable to send the request. + /// The reducer will run asynchronously in the future, + /// and its status can be observed with the `callback`. + fn gw_split_item_then( + &self, + request_actor: SessionActor, + from_slot: u8, + to_slot: u8, + count: u32, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()>; +} + +impl gw_split_item for super::RemoteReducers { + fn gw_split_item_then( + &self, + request_actor: SessionActor, + from_slot: u8, + to_slot: u8, + count: u32, + + callback: impl FnOnce(&super::ReducerEventContext, Result, __sdk::InternalError>) + + Send + + 'static, + ) -> __sdk::Result<()> { + self.imp.invoke_reducer_with_callback( + GwSplitItemArgs { + request_actor, + from_slot, + to_slot, + count, + }, + callback, + ) + } +} diff --git a/gateway/src/stdb/bindings/mod.rs b/gateway/src/stdb/bindings/mod.rs index d87e9dabe..5b78847ef 100644 --- a/gateway/src/stdb/bindings/mod.rs +++ b/gateway/src/stdb/bindings/mod.rs @@ -772,6 +772,7 @@ pub mod gw_client_command_reducer; pub mod gw_del_friend_reducer; pub mod gw_del_ignore_reducer; pub mod gw_destroy_guild_charter_reducer; +pub mod gw_destroy_item_reducer; pub mod gw_disenchant_reducer; pub mod gw_duel_accept_reducer; pub mod gw_duel_cancel_reducer; @@ -828,6 +829,7 @@ pub mod gw_set_trade_gold_reducer; pub mod gw_set_trade_item_reducer; pub mod gw_skin_reducer; pub mod gw_spirit_res_reducer; +pub mod gw_split_item_reducer; pub mod gw_stop_attack_reducer; pub mod gw_take_loot_reducer; pub mod gw_taxi_node_status_reducer; @@ -1952,6 +1954,7 @@ pub use gw_client_command_reducer::gw_client_command; pub use gw_del_friend_reducer::gw_del_friend; pub use gw_del_ignore_reducer::gw_del_ignore; pub use gw_destroy_guild_charter_reducer::gw_destroy_guild_charter; +pub use gw_destroy_item_reducer::gw_destroy_item; pub use gw_disenchant_reducer::gw_disenchant; pub use gw_duel_accept_reducer::gw_duel_accept; pub use gw_duel_cancel_reducer::gw_duel_cancel; @@ -2008,6 +2011,7 @@ pub use gw_set_trade_gold_reducer::gw_set_trade_gold; pub use gw_set_trade_item_reducer::gw_set_trade_item; pub use gw_skin_reducer::gw_skin; pub use gw_spirit_res_reducer::gw_spirit_res; +pub use gw_split_item_reducer::gw_split_item; pub use gw_stop_attack_reducer::gw_stop_attack; pub use gw_take_loot_reducer::gw_take_loot; pub use gw_taxi_node_status_reducer::gw_taxi_node_status; @@ -3518,6 +3522,7 @@ pub enum Reducer { }, GwBindHome { request_actor: SessionActor, + innkeeper_guid: u64, }, GwBusyTrade { request_actor: SessionActor, @@ -3591,6 +3596,11 @@ pub enum Reducer { request_actor: SessionActor, charter_item_guid: u64, }, + GwDestroyItem { + request_actor: SessionActor, + slot: u8, + count: u32, + }, GwDisenchant { request_actor: SessionActor, slot: u8, @@ -3834,6 +3844,12 @@ pub enum Reducer { GwSpiritRes { request_actor: SessionActor, }, + GwSplitItem { + request_actor: SessionActor, + from_slot: u8, + to_slot: u8, + count: u32, + }, GwStopAttack { request_actor: SessionActor, }, @@ -4744,6 +4760,7 @@ impl __sdk::Reducer for Reducer { Reducer::GwDelFriend { .. } => "gw_del_friend", Reducer::GwDelIgnore { .. } => "gw_del_ignore", Reducer::GwDestroyGuildCharter { .. } => "gw_destroy_guild_charter", + Reducer::GwDestroyItem { .. } => "gw_destroy_item", Reducer::GwDisenchant { .. } => "gw_disenchant", Reducer::GwDuelAccept { .. } => "gw_duel_accept", Reducer::GwDuelCancel { .. } => "gw_duel_cancel", @@ -4799,6 +4816,7 @@ impl __sdk::Reducer for Reducer { Reducer::GwSetTradeItem { .. } => "gw_set_trade_item", Reducer::GwSkin { .. } => "gw_skin", Reducer::GwSpiritRes { .. } => "gw_spirit_res", + Reducer::GwSplitItem { .. } => "gw_split_item", Reducer::GwStopAttack { .. } => "gw_stop_attack", Reducer::GwTakeLoot { .. } => "gw_take_loot", Reducer::GwTaxiNodeStatus { .. } => "gw_taxi_node_status", @@ -6994,8 +7012,10 @@ Reducer::DebugVerifyRangedLethalDamageFloorFixture{ }), Reducer::GwBindHome{ request_actor, + innkeeper_guid, } => __sats::bsatn::to_vec(&gw_bind_home_reducer::GwBindHomeArgs { request_actor: request_actor.clone(), + innkeeper_guid: innkeeper_guid.clone(), }), Reducer::GwBusyTrade{ request_actor, @@ -7124,6 +7144,15 @@ Reducer::DebugVerifyRangedLethalDamageFloorFixture{ } => __sats::bsatn::to_vec(&gw_destroy_guild_charter_reducer::GwDestroyGuildCharterArgs { request_actor: request_actor.clone(), charter_item_guid: charter_item_guid.clone(), +}), + Reducer::GwDestroyItem{ + request_actor, + slot, + count, +} => __sats::bsatn::to_vec(&gw_destroy_item_reducer::GwDestroyItemArgs { + request_actor: request_actor.clone(), + slot: slot.clone(), + count: count.clone(), }), Reducer::GwDisenchant{ request_actor, @@ -7556,6 +7585,17 @@ Reducer::GwIgnoreTrade{ request_actor, } => __sats::bsatn::to_vec(&gw_spirit_res_reducer::GwSpiritResArgs { request_actor: request_actor.clone(), +}), + Reducer::GwSplitItem{ + request_actor, + from_slot, + to_slot, + count, +} => __sats::bsatn::to_vec(&gw_split_item_reducer::GwSplitItemArgs { + request_actor: request_actor.clone(), + from_slot: from_slot.clone(), + to_slot: to_slot.clone(), + count: count.clone(), }), Reducer::GwStopAttack{ request_actor, diff --git a/gateway/src/stdb/bindings/xp_event_type.rs b/gateway/src/stdb/bindings/xp_event_type.rs index 791759a4c..27219c096 100644 --- a/gateway/src/stdb/bindings/xp_event_type.rs +++ b/gateway/src/stdb/bindings/xp_event_type.rs @@ -13,6 +13,7 @@ pub struct XpEvent { pub total_exp: u32, pub created_at: __sdk::Timestamp, pub is_kill: bool, + pub rested_bonus: u32, } impl __sdk::InModule for XpEvent { @@ -29,6 +30,7 @@ pub struct XpEventCols { pub total_exp: __sdk::__query_builder::Col, pub created_at: __sdk::__query_builder::Col, pub is_kill: __sdk::__query_builder::Col, + pub rested_bonus: __sdk::__query_builder::Col, } impl __sdk::__query_builder::HasCols for XpEvent { @@ -41,6 +43,7 @@ impl __sdk::__query_builder::HasCols for XpEvent { total_exp: __sdk::__query_builder::Col::new(table_name, "total_exp"), created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), is_kill: __sdk::__query_builder::Col::new(table_name, "is_kill"), + rested_bonus: __sdk::__query_builder::Col::new(table_name, "rested_bonus"), } } } diff --git a/gateway/src/stdb/reducers.rs b/gateway/src/stdb/reducers.rs index ff943b02e..aa87acf2b 100644 --- a/gateway/src/stdb/reducers.rs +++ b/gateway/src/stdb/reducers.rs @@ -14,8 +14,8 @@ use super::views::entity_view; use crate::world::guild_fee; use crate::world::party::{AdmittedCompanionCommand, CompanionCommandOutcome, PartyOutcome}; use crate::world::{ - ChannelOutcome, ChatOutcome, ContactOutcome, ItemActionResult, LootActionStatus, - LootWindowRefusal, LootWindowRequestStatus, MeetingStoneOutcome, + ChannelOutcome, ChatOutcome, ContactOutcome, InteractionOutcome, ItemActionResult, + LootActionStatus, LootWindowRefusal, LootWindowRequestStatus, MeetingStoneOutcome, }; use lyracore_shared::auction::AuctionRefusal; use lyracore_shared::channel::ChannelRefusal; @@ -2761,16 +2761,16 @@ impl Coordinator { _account_id: u64, actor_guid: u64, trainer_guid: u64, - ) -> Result<()> { + ) -> Result { if actor_guid == 0 { return Err(anyhow!("reset_talents: actor_guid unresolved")); } let coord = self.0.call_pipe(); - call_reducer!( + interaction_outcome(call_reducer!( coord.conn.reducers, "gw_reset_talents", gw_reset_talents_then(self.session_actor(actor_guid), trainer_guid) - ) + )) } /// Fishing cast: instant-resolve catch — the module's lenient alpha gate auto-learns the @@ -2824,7 +2824,7 @@ impl Coordinator { } /// 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 + /// u32 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( &self, @@ -2832,16 +2832,16 @@ impl Coordinator { actor_guid: u64, reputation_index: u32, at_war: bool, - ) -> Result<()> { + ) -> Result { if actor_guid == 0 { return Err(anyhow!("set_faction_at_war: actor_guid unresolved")); } let coord = self.0.call_pipe(); - call_reducer!( + interaction_outcome(call_reducer!( coord.conn.reducers, "gw_set_faction_at_war", gw_set_faction_at_war_then(self.session_actor(actor_guid), reputation_index, at_war) - ) + )) } /// Sell the item in inventory `slot` back to a vendor (`CMSG_SELL_ITEM`, Tier 2) over the @@ -2964,19 +2964,22 @@ impl Coordinator { )) } - /// Bind the caller's hearthstone home to their current position (`CMSG_GOSSIP_SELECT_OPTION` on an - /// innkeeper's "Make this inn your home.") over the coordinator connection so the module attributes - /// it to the caller's entity. No args — `bind_home` resolves the caller via `ctx.sender`. - pub fn bind_home(&self, _account_id: u64, actor_guid: u64) -> Result<()> { + /// Bind the Character's home at the selected innkeeper. + pub fn bind_home( + &self, + _account_id: u64, + actor_guid: u64, + innkeeper_guid: u64, + ) -> Result { if actor_guid == 0 { return Err(anyhow!("bind_home: actor_guid unresolved")); } let coord = self.0.call_pipe(); - call_reducer!( + interaction_outcome(call_reducer!( coord.conn.reducers, "gw_bind_home", - gw_bind_home_then(self.session_actor(actor_guid)) - ) + gw_bind_home_then(self.session_actor(actor_guid), innkeeper_guid) + )) } /// Move (or swap) main-inventory `from_slot` → `to_slot` (`CMSG_SWAP_INV_ITEM`/`CMSG_SWAP_ITEM`) @@ -2999,6 +3002,43 @@ impl Coordinator { )) } + pub fn split_item( + &self, + _account_id: u64, + actor_guid: u64, + from_slot: u8, + to_slot: u8, + count: u32, + ) -> Result { + let Some(actor_guid) = resolved_item_actor("split_item", actor_guid) else { + return Ok(ItemRefusal::Internal.into()); + }; + let coord = self.0.call_pipe(); + item_action(call_reducer!( + coord.conn.reducers, + "gw_split_item", + gw_split_item_then(self.session_actor(actor_guid), from_slot, to_slot, count) + )) + } + + pub fn destroy_item( + &self, + _account_id: u64, + actor_guid: u64, + slot: u8, + count: u32, + ) -> Result { + let Some(actor_guid) = resolved_item_actor("destroy_item", actor_guid) else { + return Ok(ItemRefusal::Internal.into()); + }; + let coord = self.0.call_pipe(); + item_action(call_reducer!( + coord.conn.reducers, + "gw_destroy_item", + gw_destroy_item_then(self.session_actor(actor_guid), slot, count) + )) + } + /// Auto-bank/auto-store-bank the item in `slot` (`CMSG_AUTOBANK_ITEM`/`CMSG_AUTOSTORE_BANK_ITEM`) /// over the coordinator connection. The module infers deposit vs. withdraw from `slot` and /// resolves the receiving free slot itself. @@ -4086,6 +4126,16 @@ fn item_action(result: Result<()>) -> Result { } } +fn interaction_outcome(result: Result<()>) -> Result { + match result { + Ok(()) => Ok(InteractionOutcome::Done), + Err(error) => match reducer_refusal_reason(&error) { + Some(reason) => Ok(InteractionOutcome::Refused(reason.to_owned())), + None => Err(error), + }, + } +} + /// An item action needs the caller's own entity. Without one there is nothing to request, so the /// client gets a Refusal rather than a dead session. fn resolved_item_actor(operation: &str, actor_guid: u64) -> Option { @@ -4618,6 +4668,36 @@ mod item_reducer_tests { } } +#[cfg(test)] +mod interaction_reducer_tests { + use super::*; + use crate::stdb::connection::ReducerCallError; + + #[test] + fn interaction_outcomes_keep_module_refusals_separate_from_infrastructure_failures() { + assert_eq!( + interaction_outcome(Ok(())).unwrap(), + InteractionOutcome::Done + ); + let refusal = anyhow::Error::from(ReducerCallError::Rejected { + operation: "gw_bind_home".into(), + reason: "innkeeper out of range".into(), + }) + .context("request completion"); + assert_eq!( + interaction_outcome(Err(refusal)).unwrap(), + InteractionOutcome::Refused("innkeeper out of range".into()) + ); + for failure in [ + anyhow::Error::from(ReducerCallError::fatal("request timed out".into())), + anyhow::Error::from(ReducerCallError::fatal("transport disconnected".into())), + anyhow!("innkeeper out of range"), + ] { + assert!(interaction_outcome(Err(failure)).is_err()); + } + } +} + #[cfg(test)] mod loot_reducer_tests { use super::*; diff --git a/gateway/src/stdb/store/npc.rs b/gateway/src/stdb/store/npc.rs index 07365314d..71b06dabf 100644 --- a/gateway/src/stdb/store/npc.rs +++ b/gateway/src/stdb/store/npc.rs @@ -3,7 +3,7 @@ use anyhow::Result; use crate::codec; -use crate::world::NpcStore; +use crate::world::{InteractionOutcome, NpcStore}; use crate::stdb::Coordinator; @@ -37,8 +37,13 @@ impl NpcStore for Coordinator { self.npc_refuses_interaction(npc_guid, player_guid) } - fn bind_home(&self, account_id: u64, self_guid: u64) -> Result<()> { - self.bind_home(account_id, self_guid) + fn bind_home( + &self, + account_id: u64, + self_guid: u64, + innkeeper_guid: u64, + ) -> Result { + self.bind_home(account_id, self_guid, innkeeper_guid) } fn npc_is_innkeeper(&self, guid: u64) -> Result { diff --git a/gateway/src/stdb/store/trainer.rs b/gateway/src/stdb/store/trainer.rs index 9bd61ae0f..349b8b035 100644 --- a/gateway/src/stdb/store/trainer.rs +++ b/gateway/src/stdb/store/trainer.rs @@ -3,11 +3,23 @@ use anyhow::Result; use crate::codec; -use crate::world::TrainerStore; +use crate::world::{InteractionOutcome, TrainerStore}; use crate::stdb::Coordinator; impl TrainerStore for Coordinator { + fn talent_reset_cost(&self, character_guid: u64) -> Option { + use crate::stdb::bindings::game_character_table::GameCharacterTableAccess; + self.0 + .coord() + .conn + .db + .game_character() + .guid() + .find(&character_guid) + .map(|character| lyracore_shared::talent::respec_cost_copper(character.respec_count)) + } + fn trainer_serves(&self, player_guid: u64, trainer_guid: u64) -> Result { self.trainer_serves(player_guid, trainer_guid) } @@ -46,7 +58,7 @@ impl TrainerStore for Coordinator { self_guid: u64, reputation_index: u32, at_war: bool, - ) -> Result<()> { + ) -> Result { self.set_faction_at_war(account_id, self_guid, reputation_index, at_war) } @@ -73,7 +85,12 @@ impl TrainerStore for Coordinator { self.learn_talent(account_id, self_guid, talent_id) } - fn reset_talents(&self, account_id: u64, self_guid: u64, trainer_guid: u64) -> Result<()> { + fn reset_talents( + &self, + account_id: u64, + self_guid: u64, + trainer_guid: u64, + ) -> Result { self.reset_talents(account_id, self_guid, trainer_guid) } diff --git a/gateway/src/stdb/subscriptions.rs b/gateway/src/stdb/subscriptions.rs index efee55be9..cc7b8a4ac 100644 --- a/gateway/src/stdb/subscriptions.rs +++ b/gateway/src/stdb/subscriptions.rs @@ -142,25 +142,6 @@ impl Drop for PlayerSubscriptions { } } -/// Bag-content slot layout — mirrors the identically-named constants in `module/src/items/ops.rs`. -/// Flat slot space: equipped bags occupy slots 19..=22 (`BAG_SLOT_START..=22`), their CONTENTS -/// live in the flat range `BAG_CONTENT_OFFSET..BAG_CONTENT_END` (120..191 inclusive). Slot math: -/// `bag_idx = (slot - 120) / 18`, `slot_in_bag = (slot - 120) % 18`, `bag_equip = 19 + bag_idx`. -const BAG_CONTENT_OFFSET: u8 = 120; -const MAX_BAG_SIZE: u8 = 18; -const BAG_SLOT_START: u8 = 19; - -/// Decompose a bag-content slot (`120..=191`) into `(bag_equip_slot, slot_in_bag)`. Returns `None` -/// for slots outside the bag-content range. `bag_equip_slot` is the slot where the bag itself is -/// equipped (19..=22); `slot_in_bag` is 0-indexed within that bag (0..MAX_BAG_SIZE). -fn bag_content_parts(slot: u8) -> Option<(u8, u8)> { - if slot < BAG_CONTENT_OFFSET { - return None; - } - let rel = slot - BAG_CONTENT_OFFSET; - Some((BAG_SLOT_START + rel / MAX_BAG_SIZE, rel % MAX_BAG_SIZE)) -} - /// Build the wire relay for one `game_teleport_event` row. Pure: takes the /// already-derived `still_here` boolean (whether the live entity survived the transaction — the /// module/gateway-mirrored same-map/cross-map signal, see the `on_teleport` callback's doc comment) @@ -229,10 +210,13 @@ pub(crate) fn hunter_pet_outbound(row: &HunterPetProtocol) -> Vec { /// Build the XP-gain result for one identity-addressed row. pub(crate) fn xp_event_outbound(row: &XpEvent) -> Vec { - let message = codec::build_log_xpgain(row.killed_guid, row.total_exp, row.is_kill); - vec![Outbound::One(ServerOpcodeMessage::SMSG_LOG_XPGAIN( - Box::new(message), - ))] + let (opcode, body) = codec::build_log_xpgain_raw( + row.killed_guid, + row.total_exp, + row.is_kill, + row.rested_bonus, + ); + vec![Outbound::Raw { opcode, body }] } /// Build the level-up result for one identity-addressed row. @@ -305,7 +289,32 @@ fn build_peer_create( |guid| guild_projection_of(coord, guid), ); let auras = world.auras.on_target(shard, row.guid); - match peer_create_outbound(&view, &inv, &auras) { + let movement = coord + .0 + .coord() + .conn + .db + .game_entity_motion() + .guid() + .find(&row.guid) + .filter(|motion| motion.map_id == row.map_id && motion.instance_id == row.instance_id) + .filter(|motion| { + codec::bytes_to_movement_info(&motion.movement_info).is_ok_and(|info| { + info.timestamp == row.last_move_ms + && info.position.x == row.x + && info.position.y == row.y + && info.position.z == row.z + && info.orientation == row.orientation + }) + }); + match peer_create_outbound( + &view, + &inv, + &auras, + movement + .as_ref() + .map(|motion| motion.movement_info.as_slice()), + ) { Ok(out) => Some(out), Err(e) => { log::warn!("peer create encode failed for guid {}: {e}", row.guid); @@ -335,11 +344,17 @@ fn peer_create_outbound( view: &codec::EntityView, inventory: &[codec::ItemInstanceView], auras: &[Aura], + movement: Option<&[u8]>, ) -> Result> { let create = codec::build_create_object(view, CreateKind::Peer, inventory, &[])?; - let mut out = vec![Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( - Box::new(create), - ))]; + let created = match movement { + Some(movement) => Outbound::Raw { + opcode: 0x00a9, + body: codec::create_with_movement(&create, movement)?, + }, + None => Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(Box::new(create))), + }; + let mut out = vec![created]; if !auras.is_empty() { out.push(aura_sync(auras.iter().cloned(), view.guid)); } @@ -1693,6 +1708,56 @@ pub(crate) fn stealth_visibility( } } +/// Channel timers belong to the caster, including channels whose auras sit on an enemy. +fn spell_channel_outbound( + coord: &Coordinator, + self_guid: u64, + row: &Aura, + removed: bool, +) -> Option { + use wow_world_messages::vanilla::{MSG_CHANNEL_START_Server, MSG_CHANNEL_UPDATE_Server}; + if row.caster_guid != self_guid { + return None; + } + let guard = coord.0.coord(); + let db = &guard.conn.db; + let is_channel = |aura: &Aura| { + let flags = db + .game_spell() + .spell_id() + .find(&aura.spell_id) + .map_or(0, |spell| spell.cast_flags); + lyracore_shared::spell::is_channel_aura(aura.eff_kind, flags) + }; + if !is_channel(row) { + return None; + } + let active = db + .game_aura() + .iter() + .filter(|aura| aura.caster_guid == self_guid && is_channel(aura)) + .min_by_key(|aura| aura.id); + let packet = if removed { + if active.is_some() { + return None; + } + ServerOpcodeMessage::MSG_CHANNEL_UPDATE(MSG_CHANNEL_UPDATE_Server { time: 0 }) + } else { + if active.as_ref().is_none_or(|active| active.id != row.id) { + return None; + } + let duration = ((row.expires_at.to_micros_since_unix_epoch() + - row.applied_at.to_micros_since_unix_epoch()) + / 1000) + .clamp(0, u32::MAX as i64) as u32; + ServerOpcodeMessage::MSG_CHANNEL_START(MSG_CHANNEL_START_Server { + spell: row.spell_id, + duration, + }) + }; + Some(Outbound::One(packet)) +} + /// Aura insert leg: array sync, self-only duration/run-speed/armor/sheet packets, and the /// stealth HIDE transition. The target's current aura set comes from the gateway's aura index, /// post-change like the cache it mirrors; `stealth_count` was taken on the pump. @@ -1708,6 +1773,7 @@ pub(crate) fn aura_insert_outbound( stealth_count: usize, ) -> Vec { let mut out = Vec::new(); + out.extend(spell_channel_outbound(coord, self_guid, row, false)); // A stealth-hidden peer (not in `created`) must get NO per-peer relay: a partial VALUES on // a DESTROYed object is a client crash/desync vector. let visible = @@ -1795,6 +1861,7 @@ pub(crate) fn aura_delete_outbound( stealth_count: usize, ) -> Vec { let mut out = Vec::new(); + out.extend(spell_channel_outbound(coord, self_guid, row, true)); let visible = row.target_guid == self_guid || created.lock().unwrap().contains(&row.target_guid); if visible { @@ -3450,8 +3517,8 @@ fn item_gain_feedback( let mut out = vec![Outbound::One(ServerOpcodeMessage::SMSG_ITEM_PUSH_RESULT( Box::new(codec::build_item_push_result( self_guid, - 255, - slot as u32, + codec::bag_content_parts(slot).map_or(255, |(bag, _)| bag), + u32::from(codec::bag_content_parts(slot).map_or(slot, |(_, position)| position)), entry, gained, stack_add, @@ -3516,7 +3583,7 @@ pub(crate) fn item_instance_insert_outbound( Box::new(values), ))); } - if let Some((bag_slot, slot_in_bag)) = bag_content_parts(row.slot) { + if let Some((bag_slot, slot_in_bag)) = codec::bag_content_parts(row.slot) { if let Some(bag) = db .game_item_instance() .iter() @@ -3564,7 +3631,7 @@ pub(crate) fn item_instance_delete_outbound( Box::new(values), ))); } - if let Some((bag_slot, slot_in_bag)) = bag_content_parts(row.slot) { + if let Some((bag_slot, slot_in_bag)) = codec::bag_content_parts(row.slot) { if let Some(bag) = db .game_item_instance() .iter() @@ -3696,7 +3763,7 @@ fn append_final_item_slots( } } for (slot, item) in slots { - if let Some((bag_slot, slot_in_bag)) = bag_content_parts(slot) { + if let Some((bag_slot, slot_in_bag)) = codec::bag_content_parts(slot) { if let Some(bag) = item_in_slot(bag_slot) { let (opcode, body) = codec::build_container_slot_values(bag.guid, slot_in_bag, item.guid); @@ -5488,7 +5555,7 @@ mod tests { assert_eq!(guid, row.guid); (7, 3) }); - let player = created_player(&peer_create_outbound(&view, &[], &[]).unwrap()); + let player = created_player(&peer_create_outbound(&view, &[], &[], None).unwrap()); assert_eq!(player.player_guildid(), Some(7)); assert_eq!(player.player_guildrank(), Some(3)); } @@ -5497,7 +5564,7 @@ mod tests { fn a_peer_outside_any_guild_is_created_with_an_explicit_zero() { let row = human_warrior(); let player = created_player( - &peer_create_outbound(&peer_entity_view(&row, 0, |_| (0, 0)), &[], &[]).unwrap(), + &peer_create_outbound(&peer_entity_view(&row, 0, |_| (0, 0)), &[], &[], None).unwrap(), ); assert_eq!(player.player_guildid(), Some(0)); assert_eq!(player.player_guildrank(), Some(0)); @@ -6412,6 +6479,7 @@ mod tests { ..Default::default() }], &index.on_target(0, view.guid), + None, ) .unwrap(); let [Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(create)), Outbound::Raw { opcode, body }] = @@ -6451,7 +6519,7 @@ mod tests { fn peer_create_without_auras_needs_no_followup() { let mut entity = player_entity(); entity.unit_bytes_0 = 0x0000_0101; - let out = peer_create_outbound(&entity_view(entity, 0), &[], &[]).unwrap(); + let out = peer_create_outbound(&entity_view(entity, 0), &[], &[], None).unwrap(); assert!(matches!( out.as_slice(), [Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT(_))] @@ -6504,15 +6572,12 @@ mod tests { #[test] fn bag_content_parts_boundaries() { - assert_eq!(bag_content_parts(119), None); // just below the bag-content range - assert_eq!(bag_content_parts(120), Some((19, 0))); // first bag, first slot - assert_eq!(bag_content_parts(137), Some((19, 17))); // first bag, last slot (0..MAX_BAG_SIZE) - assert_eq!(bag_content_parts(138), Some((20, 0))); // second bag, first slot - assert_eq!(bag_content_parts(191), Some((22, 17))); // fourth (last) bag, last slot - // The function has no upper-bound gate on the nominal 4-bag range (that gate lives at the - // caller, which only ever looks up a bag that actually exists) — pin its actual unclamped - // behavior past 191 rather than asserting an invariant it doesn't enforce. - assert_eq!(bag_content_parts(192), Some((23, 0))); + assert_eq!(codec::bag_content_parts(119), None); // just below the bag-content range + assert_eq!(codec::bag_content_parts(120), Some((19, 0))); // first bag, first slot + assert_eq!(codec::bag_content_parts(137), Some((19, 17))); // first bag, last slot (0..MAX_BAG_SIZE) + assert_eq!(codec::bag_content_parts(138), Some((20, 0))); // second bag, first slot + assert_eq!(codec::bag_content_parts(191), Some((22, 17))); // fourth (last) bag, last slot + assert_eq!(codec::bag_content_parts(192), None); } fn item_view(guid: u64, entry: u32) -> codec::ItemInstanceView { @@ -7175,7 +7240,7 @@ mod tests { fn creature_create_outbound() -> Vec { let mut creature = creature_entity(); creature.unit_bytes_0 = 0x0000_0101; - peer_create_outbound(&entity_view(creature, 0), &[], &[]).unwrap() + peer_create_outbound(&entity_view(creature, 0), &[], &[], None).unwrap() } #[test] diff --git a/gateway/src/stdb/world_view.rs b/gateway/src/stdb/world_view.rs index 2de570cf9..0974bd4ec 100644 --- a/gateway/src/stdb/world_view.rs +++ b/gateway/src/stdb/world_view.rs @@ -1101,7 +1101,10 @@ fn register_shard_callbacks( db.game_character(), "game_character.update", &view, - |v, old, new| character_online_changed(v, old, new), + move |v, old, new| { + character_home_changed(v, shard, old, new); + character_online_changed(v, old, new); + }, ); wire_insert_live( db.game_mail_arrival(), @@ -2535,6 +2538,42 @@ fn friend_notice_candidates(view: &WorldView, subject_guid: u64) -> Vec Vec> { let key = view .spatial .entity_cell_on_shard(EntityLayer::WorldEntity, row.target_guid, shard); - view.cell_audience(shard, key, BOX_HALF_SPAN, &[row.target_guid]) + view.cell_audience( + shard, + key, + BOX_HALF_SPAN, + &[row.target_guid, row.caster_guid], + ) } /// An aura appeared → per viewer: array sync + self-only packets + the stealth HIDE transition @@ -3367,6 +3409,16 @@ mod cell_audience_tests; #[cfg(test)] mod relay_bench; +#[cfg(all(test, unix))] +mod benilla_movement_tests { + #[test] + fn benilla_clients_observe_each_others_movement_within_aoi() { + crate::world::tests::benilla_tests::two_clients_observe_movement(|view, row| { + super::motion(view, 0, row, &Default::default()); + }); + } +} + #[allow(clippy::items_after_test_module)] #[cfg(test)] mod family_audience_tests { @@ -4971,6 +5023,7 @@ mod family_audience_tests { total_exp: 123, created_at: spacetimedb_sdk::Timestamp::UNIX_EPOCH, is_kill: true, + rested_bonus: 0, }, ); levelup_appeared( @@ -5016,7 +5069,7 @@ mod family_audience_tests { ); assert!(matches!( queued_job(&owner_rx).as_slice(), - [Outbound::One(ServerOpcodeMessage::SMSG_LOG_XPGAIN(_))] + [Outbound::Raw { opcode: 0x01d0, .. }] )); assert!(matches!( queued_job(&owner_rx).as_slice(), diff --git a/gateway/src/world/benilla_tests.rs b/gateway/src/world/benilla_tests.rs new file mode 100644 index 000000000..ff8084ce9 --- /dev/null +++ b/gateway/src/world/benilla_tests.rs @@ -0,0 +1,1370 @@ +//! Headless Client checks through the WorldStore Seam, using Benilla's independent codec. + +use super::{tester_store, warrior_entity, world_session_socket_pair, WorldFake}; +use crate::logon::{LogonAccount, LogonStore, RealmInfo}; +use crate::world::run_world_session; +use benilla_protocol::messages::{self, opcode, Object, ServerPacket}; +use benilla_srp::vanilla_header::{HeaderCrypto, ProofSeed}; +use benilla_srp::NormalizedString; +use std::io::{Read, Write}; +use std::os::unix::net::UnixStream; +use std::sync::{Arc, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[path = "benilla_tests/durable.rs"] +mod durable; + +struct Client { + socket: UnixStream, + crypto: HeaderCrypto, + objects: std::collections::BTreeMap>, +} + +impl Client { + fn connect(socket: UnixStream, key: [u8; 40]) -> Self { + Self::connect_named(socket, key, "TESTER") + } + + fn connect_named(mut socket: UnixStream, key: [u8; 40], username: &str) -> Self { + let (opcode, body) = read_frame(&mut socket, None); + let ServerPacket::AuthChallenge { server_seed } = decode(opcode, &body) else { + panic!("expected world authentication challenge"); + }; + let seed = ProofSeed::new(); + let client_seed = seed.seed(); + let (proof, crypto) = seed.into_client_header_crypto( + &NormalizedString::new(username).unwrap(), + key, + server_seed, + ); + let body = messages::auth_session( + u32::from(benilla_protocol::CLIENT_BUILD), + username, + client_seed, + &proof, + &messages::STOCK_SECURE_ADDONS, + ); + write_frame(&mut socket, None, opcode::CMSG_AUTH_SESSION, &body); + let mut client = Self { + socket, + crypto, + objects: Default::default(), + }; + assert!(matches!( + client.recv(), + ServerPacket::AuthResponse { + result: messages::AUTH_OK, + .. + } + )); + client + } + + fn send(&mut self, opcode: u16, body: &[u8]) { + write_frame(&mut self.socket, Some(&mut self.crypto), opcode, body); + } + + fn recv(&mut self) -> ServerPacket { + let (opcode, body) = read_frame(&mut self.socket, Some(&mut self.crypto)); + let packet = decode(opcode, &body); + match &packet { + ServerPacket::UpdateObject { objects } => { + for object in objects { + match object { + Object::Create { guid, mask, .. } => { + self.objects.insert(*guid, mask.raw_fields().collect()); + } + Object::Values { guid, mask } => { + self.objects + .entry(*guid) + .or_default() + .extend(mask.raw_fields()); + } + Object::OutOfRange { guids } => { + for guid in guids { + self.objects.remove(guid); + } + } + _ => {} + } + } + } + ServerPacket::DestroyObject { guid } => { + self.objects.remove(guid); + } + _ => {} + } + packet + } + + fn enter_world(&mut self) -> Vec { + self.enter_world_as(1, "Tester") + } + + fn enter_world_as(&mut self, self_guid: u64, name: &str) -> Vec { + self.send(opcode::CMSG_CHAR_ENUM, &[]); + loop { + if let ServerPacket::CharEnum { characters } = self.recv() { + assert_eq!(characters.len(), 1); + assert_eq!(characters[0].guid, self_guid); + assert_eq!(characters[0].name, name); + break; + } + } + self.send(opcode::CMSG_PLAYER_LOGIN, &messages::full_guid(self_guid)); + let mut verified_world = false; + let mut created = Vec::new(); + loop { + match self.recv() { + ServerPacket::LoginVerifyWorld { map, position, .. } => { + assert_eq!(map, 0); + assert!((position.x - -8949.95).abs() < 0.01); + verified_world = true; + } + ServerPacket::UpdateObject { objects } => { + for object in objects { + if let Object::Create { guid, ref mask, .. } = object { + if guid == self_guid { + assert!( + verified_world, + "world verification must precede the self create" + ); + assert!(mask.unit_level().is_some()); + created.push(object); + return created; + } + } + created.push(object); + } + } + _ => {} + } + } + } + + fn query_clock(&mut self) { + let before = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + self.send(opcode::CMSG_QUERY_TIME, &messages::query_time()); + loop { + if let ServerPacket::QueryTimeResponse { unix_time } = self.recv() { + let after = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + assert!((before..=after).contains(&u64::from(unix_time))); + return; + } + } + } + + fn ping(&mut self, sequence: u32) { + self.send(opcode::CMSG_PING, &messages::ping(sequence, 37)); + match self.recv() { + ServerPacket::Pong { sequence: actual } => assert_eq!(actual, sequence), + other => panic!("expected SMSG_PONG, got {}", other.name()), + } + } + + fn logout(&mut self) { + self.send(opcode::CMSG_LOGOUT_REQUEST, &[]); + assert!(matches!( + self.recv(), + ServerPacket::LogoutResponse { reason: 0, .. } + )); + assert!(matches!(self.recv(), ServerPacket::LogoutComplete)); + } +} + +fn write_frame( + socket: &mut UnixStream, + crypto: Option<&mut HeaderCrypto>, + opcode: u16, + body: &[u8], +) { + let size = u16::try_from(body.len() + 4).unwrap().to_be_bytes(); + let opcode = u32::from(opcode).to_le_bytes(); + let mut header = [size[0], size[1], opcode[0], opcode[1], opcode[2], opcode[3]]; + if let Some(crypto) = crypto { + crypto.encrypter().encrypt(&mut header); + } + socket.write_all(&header).unwrap(); + socket.write_all(body).unwrap(); +} + +fn read_frame(socket: &mut UnixStream, crypto: Option<&mut HeaderCrypto>) -> (u16, Vec) { + let mut header = [0; 4]; + socket + .read_exact(&mut header) + .expect("Gateway reply before the socket deadline"); + if let Some(crypto) = crypto { + crypto.decrypter().decrypt(&mut header); + } + let size = u16::from_be_bytes([header[0], header[1]]); + let opcode = u16::from_le_bytes([header[2], header[3]]); + let mut body = vec![0; usize::from(size.checked_sub(2).unwrap())]; + socket.read_exact(&mut body).unwrap(); + (opcode, body) +} + +fn decode(opcode: u16, body: &[u8]) -> ServerPacket { + let (packet, tail) = messages::parse_server_with_tail(opcode, body) + .unwrap_or_else(|error| panic!("Benilla could not decode {opcode:#06x}: {error}")); + assert_eq!(tail, 0, "unread bytes for {}", packet.name()); + if let ServerPacket::Other { .. } = packet { + // Benilla does not model these two world-entry messages. Check their 5875 lengths. + match opcode { + 0x0209 => assert_eq!(body.len(), 128, "SMSG_ACCOUNT_DATA_TIMES"), + 0x021e => assert_eq!(body.len(), 4, "SMSG_SET_REST_START"), + _ => panic!("unmodelled packet {opcode:#06x}"), + } + } + packet +} + +#[test] +fn benilla_login_clock_ping_logout_and_reconnect() { + let (client_key, saved_key) = authenticate(); + let mut store = tester_store(7); + store.session.login_entity = Some(warrior_entity()); + store.characters = vec![super::party_tests::character(1, "Tester")]; + store.session.session = Some(crate::world::WorldSession { + account_id: 7, + session_key: saved_key, + }); + let store = Arc::new(store); + for sequence in [0x1234_5678, u32::MAX] { + let (socket, gateway_socket) = world_session_socket_pair(); + let gateway_store = store.clone(); + let gateway = std::thread::spawn(move || run_world_session(gateway_socket, gateway_store)); + let mut client = Client::connect(socket, client_key); + client.enter_world(); + assert_eq!(client.objects[&1].get(&22), Some(&60), "UNIT_FIELD_HEALTH"); + assert_eq!(client.objects[&1].get(&34), Some(&1), "UNIT_FIELD_LEVEL"); + client.query_clock(); + client.ping(sequence); + client.logout(); + client.enter_world(); + client.query_clock(); + client.ping(sequence.wrapping_add(1)); + client.logout(); + drop(client); + gateway.join().unwrap().unwrap(); + } +} + +pub(crate) struct MovementWorld { + view: Arc, + relay: fn(&crate::stdb::world_view::WorldView, &crate::stdb::bindings::EntityMotion), + positions: Mutex>, +} + +impl MovementWorld { + pub(crate) fn update( + &self, + guid: u64, + opcode: u32, + info: &wow_world_messages::vanilla::MovementInfo, + ) -> anyhow::Result<()> { + let movement_info = crate::codec::movement_info_to_bytes(info)?; + self.positions.lock().unwrap().insert(guid, info.clone()); + let (grid_x, grid_y) = + lyracore_shared::spatial::grid_cell(info.position.x, info.position.y); + (self.relay)( + &self.view, + &crate::stdb::bindings::EntityMotion { + guid, + map_id: 0, + instance_id: 0, + grid_x, + grid_y, + cell: lyracore_shared::spatial::grid_cell_id(grid_x, grid_y), + opcode: u16::try_from(opcode)?, + movement_info, + seq: info.timestamp, + }, + ); + Ok(()) + } +} + +// The caller lives beside the shard callbacks, so it can supply the production motion callback +// without exposing a private production operation for tests. +pub(crate) fn two_clients_observe_movement( + relay: fn(&crate::stdb::world_view::WorldView, &crate::stdb::bindings::EntityMotion), +) { + use crate::codec; + use crate::stdb::world_view::{OwnerGuid, WorldView}; + use crate::world::Outbound; + use wow_world_messages::vanilla::opcodes::ServerOpcodeMessage; + + let view = Arc::new(WorldView::new(true)); + let world = Arc::new(MovementWorld { + view: view.clone(), + relay, + positions: Mutex::default(), + }); + let mut entities = [warrior_entity(), warrior_entity()]; + entities[1].guid = 0x0100_0003; + let mut clients = Vec::new(); + let mut gateways = Vec::new(); + for (entity, name) in entities.iter().zip(["Tester", "Observer"]) { + let mut store = tester_store(entity.guid + 7); + store.session.username = name.to_uppercase(); + store.session.login_entity = Some(entity.clone()); + store.characters = vec![super::party_tests::character(entity.guid, name)]; + store.session.relay_view = Some(view.clone()); + store.session.movement_world = Some(world.clone()); + let store = Arc::new(store); + let (socket, gateway_socket) = world_session_socket_pair(); + gateways.push(std::thread::spawn(move || { + run_world_session(gateway_socket, store) + })); + let mut client = Client::connect_named(socket, super::K, name); + client.enter_world_as(entity.guid, name); + client.query_clock(); + clients.push(client); + } + + // Supply the Fake's initial peer snapshot. Movement delivery below uses the real cell audience, + // known-object Gate, writer queue and encryption. + for index in 0..2 { + let peer = &entities[1 - index]; + let viewer = view + .viewer_of_owner(OwnerGuid(entities[index].guid)) + .unwrap(); + viewer.created.lock().unwrap().insert(peer.guid); + viewer + .tx + .send(Outbound::One(ServerOpcodeMessage::SMSG_UPDATE_OBJECT( + Box::new( + codec::build_create_object(peer, codec::CreateKind::Peer, &[], &[]).unwrap(), + ), + ))) + .unwrap(); + let ServerPacket::UpdateObject { objects } = clients[index].recv() else { + panic!("expected peer creation"); + }; + assert!(objects + .iter() + .any(|object| matches!(object, Object::Create { guid, .. } if *guid == peer.guid))); + } + + for sender in 0..2 { + for (opcode, flags) in [ + (opcode::MSG_MOVE_START_FORWARD, 1), + (opcode::MSG_MOVE_JUMP, 0x2000), + (opcode::MSG_MOVE_START_SWIM, 0x20_0000), + (opcode::MSG_MOVE_HEARTBEAT, 0x0200_0000), + (opcode::MSG_MOVE_FALL_LAND, 0x0220_2001), + ] { + let mut info = movement_info(flags); + info.position.x = -8949.0 + sender as f32; + info.position.y = -132.0; + let body = messages::movement(&info); + clients[sender].send(opcode, &body); + clients[sender].ping(u32::from(opcode)); // Also proves no movement echo to the mover. + assert_movement_packet( + clients[1 - sender].recv(), + opcode, + entities[sender].guid, + &info, + ); + let positions = world.positions.lock().unwrap(); + let stored = positions.get(&entities[sender].guid).unwrap(); + assert_eq!(stored.position.x, info.position.x); + assert_eq!(stored.position.y, -132.0); + assert_eq!(stored.position.z, 83.5); + assert_eq!(stored.timestamp, 123_456); + } + } + + let mut distant = movement_info(0); + distant.position.x = -7949.0; + distant.position.y = -132.0; + clients[1].send(opcode::MSG_MOVE_STOP, &messages::movement(&distant)); + clients[1].ping(100); + clients[0].ping(101); // The out-of-range movement must not precede this pong. + for client in &mut clients { + client.logout(); + } + drop(clients); + for gateway in gateways { + gateway.join().unwrap().unwrap(); + } +} + +struct LogonFixture { + account: LogonAccount, + saved_key: Mutex>, +} + +#[derive(Default)] +pub(crate) struct GameplayState { + pub(crate) selected: u64, + pub(crate) attacking: Option, + pub(crate) copper: u32, + pub(crate) corpse_money: u32, + pub(crate) loot: Vec, + pub(crate) inventory: Vec, +} + +fn gameplay_store(state: GameplayState) -> Arc { + let mut store = tester_store(7); + store.session.login_entity = Some(warrior_entity()); + store.characters = vec![super::party_tests::character(1, "Tester")]; + store.benilla_gameplay = Some(Mutex::new(state)); + Arc::new(store) +} + +fn client_in_world(store: Arc) -> (Client, std::thread::JoinHandle>) { + let (socket, gateway_socket) = world_session_socket_pair(); + let gateway = std::thread::spawn(move || run_world_session(gateway_socket, store)); + let mut client = Client::connect(socket, super::K); + client.enter_world(); + client.query_clock(); + (client, gateway) +} + +#[test] +fn benilla_selects_a_target_and_starts_and_stops_melee() { + let store = gameplay_store(GameplayState::default()); + let (mut client, gateway) = client_in_world(store.clone()); + let target = 0xf130_0000_0000_003c; + client.send(opcode::CMSG_SET_SELECTION, &messages::full_guid(target)); + client.send(opcode::CMSG_ATTACKSWING, &messages::attack_swing(target)); + assert!( + matches!(client.recv(), ServerPacket::AttackStart { attacker: 1, victim } if victim == target) + ); + { + let state = store.benilla_gameplay.as_ref().unwrap().lock().unwrap(); + assert_eq!(state.selected, target); + assert_eq!(state.attacking, Some(target)); + } + client.send(opcode::CMSG_ATTACKSTOP, &[]); + assert!( + matches!(client.recv(), ServerPacket::AttackStop { attacker: 1, victim, .. } if victim == target) + ); + assert_eq!( + store + .benilla_gameplay + .as_ref() + .unwrap() + .lock() + .unwrap() + .attacking, + None + ); + client.logout(); + drop(client); + gateway.join().unwrap().unwrap(); +} + +#[test] +fn benilla_loots_money_and_an_item_then_moves_it_in_inventory() { + use super::CastStore; + let store = gameplay_store(GameplayState { + corpse_money: 25, + loot: vec![(2, 117, 3, 1956, 0)], + ..Default::default() + }); + let (mut client, gateway) = client_in_world(store.clone()); + client.send(opcode::CMSG_LOOT, &messages::loot(60)); + let ServerPacket::LootResponse { + guid, gold, items, .. + } = client.recv() + else { + panic!("expected loot window"); + }; + assert_eq!(guid, 60); + assert_eq!(gold, 25); + assert_eq!(items.len(), 1); + assert_eq!(items[0].slot, 2); + assert_eq!(items[0].item_id, 117); + assert_eq!(items[0].count, 3); + client.send(opcode::CMSG_LOOT_MONEY, &messages::loot_money()); + assert!(matches!(client.recv(), ServerPacket::LootClearMoney)); + client.send( + opcode::CMSG_AUTOSTORE_LOOT_ITEM, + &messages::autostore_loot_item(2), + ); + assert!(matches!( + client.recv(), + ServerPacket::LootRemoved { slot: 2 } + )); + client.send(opcode::CMSG_LOOT_RELEASE, &messages::loot_release(60)); + assert!(matches!( + client.recv(), + ServerPacket::LootReleaseResponse { + guid: 60, + result: 1 + } + )); + { + let state = store.benilla_gameplay.as_ref().unwrap().lock().unwrap(); + assert_eq!(state.copper, 25); + assert_eq!(state.corpse_money, 0); + assert!(state.loot.is_empty()); + } + let inventory = store.player_items(1).unwrap(); + assert_eq!(inventory.len(), 1); + assert_eq!( + ( + inventory[0].entry, + inventory[0].stack_count, + inventory[0].slot + ), + (117, 3, 23) + ); + client.send(opcode::CMSG_SWAP_INV_ITEM, &messages::swap_inv_item(23, 24)); + client.ping(500); + let inventory = store.player_items(1).unwrap(); + assert_eq!(inventory[0].slot, 24); + client.logout(); + let created = client.enter_world(); + let mut found = false; + for object in created { + if let Object::Create { guid, mask, .. } = object { + if guid == inventory[0].guid { + let fields: std::collections::BTreeMap<_, _> = mask.raw_fields().collect(); + assert_eq!(fields.get(&3), Some(&117)); // OBJECT_FIELD_ENTRY + assert_eq!(fields.get(&6), Some(&1)); // ITEM_FIELD_OWNER + assert_eq!(fields.get(&14), Some(&3)); // ITEM_FIELD_STACK_COUNT + found = true; + } + } + } + assert!( + found, + "the acquired item must precede the Character's inventory pointers" + ); + client.query_clock(); + client.logout(); + drop(client); + gateway.join().unwrap().unwrap(); +} + +#[test] +fn benilla_accepts_a_quest_requests_its_reward_and_collects_it() { + use super::QuestActionStore; + let mut detail = super::detail_view(1234, "A delivery"); + detail.money_reward = 50; + detail.reward_xp = 40; + let mut store = gameplay_store(GameplayState::default()); + let setup = Arc::get_mut(&mut store).unwrap(); + setup.quest.quest_details = vec![detail]; + setup.quest.quest_evals = vec![super::eval(1234, crate::codec::ROLE_END, true, true)]; + let (mut client, gateway) = client_in_world(store.clone()); + client.send( + opcode::CMSG_QUESTGIVER_QUERY_QUEST, + &messages::questgiver_query_quest(50, 1234), + ); + let ServerPacket::QuestGiverDetails(details) = client.recv() else { + panic!("expected quest details"); + }; + assert_eq!( + (details.npc, details.quest_id, details.money), + (50, 1234, 50) + ); + client.send( + opcode::CMSG_QUESTGIVER_ACCEPT_QUEST, + &messages::questgiver_accept_quest(50, 1234), + ); + client.ping(600); + assert_eq!(store.quest_status(1, 1234), (true, false)); + client.send( + opcode::CMSG_QUESTGIVER_REQUEST_REWARD, + &messages::questgiver_request_reward(50, 1234), + ); + let ServerPacket::QuestGiverOfferReward(offer) = client.recv() else { + panic!("expected the quest reward screen"); + }; + assert_eq!((offer.npc, offer.quest_id, offer.money), (50, 1234, 50)); + client.send( + opcode::CMSG_QUESTGIVER_CHOOSE_REWARD, + &messages::questgiver_choose_reward(50, 1234, 0), + ); + let ServerPacket::QuestGiverComplete(complete) = client.recv() else { + panic!("expected quest completion"); + }; + assert_eq!( + (complete.quest_id, complete.money, complete.xp), + (1234, 50, 40) + ); + assert_eq!(store.quest_status(1, 1234), (true, true)); + assert_eq!( + store + .benilla_gameplay + .as_ref() + .unwrap() + .lock() + .unwrap() + .copper, + 50 + ); + client.logout(); + drop(client); + gateway.join().unwrap().unwrap(); +} + +#[test] +fn benilla_reward_request_for_an_incomplete_quest_keeps_the_progress_screen() { + use super::QuestActionStore; + let mut store = gameplay_store(GameplayState::default()); + let setup = Arc::get_mut(&mut store).unwrap(); + setup.quest.quest_details = vec![super::detail_view(1234, "An unfinished quest")]; + setup.quest.quest_evals = vec![super::eval(1234, crate::codec::ROLE_END, true, false)]; + setup.quest.quest_log = Mutex::new(vec![(1234, false)]); + let (mut client, gateway) = client_in_world(store.clone()); + client.send( + opcode::CMSG_QUESTGIVER_REQUEST_REWARD, + &messages::questgiver_request_reward(50, 1234), + ); + let ServerPacket::QuestGiverRequestItems(progress) = client.recv() else { + panic!("expected the incomplete quest's progress screen"); + }; + assert_eq!((progress.npc, progress.quest_id), (50, 1234)); + assert!(!progress.is_complete); + assert_eq!(store.quest_status(1, 1234), (true, false)); + assert_eq!( + store + .benilla_gameplay + .as_ref() + .unwrap() + .lock() + .unwrap() + .copper, + 0 + ); + client.logout(); + drop(client); + gateway.join().unwrap().unwrap(); +} + +impl LogonStore for LogonFixture { + fn account(&self, username: &str) -> anyhow::Result> { + Ok((username == "TESTER").then(|| self.account.clone())) + } + + fn bound_identity(&self, _: u64, _: &str) -> anyhow::Result<[u8; 32]> { + Ok([7; 32]) + } + + fn save_session(&self, _: u64, _: &str, key: &[u8; 40], _: [u8; 32]) -> anyhow::Result<()> { + *self.saved_key.lock().unwrap() = Some(*key); + Ok(()) + } + + fn realms(&self, _: u64, _: &str) -> anyhow::Result> { + Ok(vec![RealmInfo { + id: 1, + name: "Fixture".into(), + address: "127.0.0.1:8085".into(), + realm_type: 0, + population: 0.0, + number_of_characters: 1, + }]) + } +} + +fn authenticate() -> ([u8; 40], [u8; 40]) { + use benilla_protocol::auth; + use benilla_srp::{PublicKey, SrpClientChallenge}; + use wow_srp::server::SrpVerifier; + + let verifier = + SrpVerifier::from_username_and_password(super::ns("TESTER"), super::ns("PASSWORD")); + let store = Arc::new(LogonFixture { + account: LogonAccount { + id: 7, + salt: *verifier.salt(), + verifier: *verifier.password_verifier(), + banned: false, + }, + saved_key: Mutex::new(None), + }); + // Match Benilla's logon policy for public keys whose integer encoding has an ambiguous width. + for _ in 0..8 { + let (mut socket, mut gateway_socket) = world_session_socket_pair(); + let logon_store = store.clone(); + let gateway = std::thread::spawn(move || { + crate::logon::handle_logon(&mut gateway_socket, logon_store.as_ref()) + }); + auth::write_logon_challenge(&mut socket, "TESTER", 5875).unwrap(); + let reply = auth::read_challenge_reply(&mut socket).unwrap(); + let public_key = PublicKey::from_le_bytes(reply.server_public_key).unwrap(); + if !public_key.is_width_stable() { + drop(socket); + gateway.join().unwrap().unwrap(); + continue; + } + let challenge = SrpClientChallenge::new( + NormalizedString::new("TESTER").unwrap(), + NormalizedString::new("PASSWORD").unwrap(), + reply.generator, + reply.large_safe_prime, + public_key, + reply.salt, + ); + auth::write_logon_proof( + &mut socket, + challenge.client_public_key(), + challenge.client_proof(), + &reply.crc_salt, + ) + .unwrap(); + let proof = auth::read_proof_reply(&mut socket).unwrap(); + let authenticated = challenge.verify_server_proof(proof).unwrap(); + auth::write_realm_list_request(&mut socket).unwrap(); + let realms = auth::read_realm_list(&mut socket).unwrap(); + assert_eq!(realms.len(), 1); + assert_eq!(realms[0].name, "Fixture"); + assert_eq!(realms[0].characters, 1); + drop(socket); + gateway.join().unwrap().unwrap(); + return ( + *authenticated.session_key(), + store.saved_key.lock().unwrap().unwrap(), + ); + } + panic!("no width-stable SRP challenge in eight attempts"); +} + +#[test] +fn benilla_movement_tails_survive_gateway_decode_and_relay() { + for flags in [0, 0x2000, 0x20_0000] { + assert_movement_relay(movement_info(flags)); + } +} + +#[test] +fn benilla_movement_acknowledgements_keep_the_world_session_usable() { + use messages::{MoveMode, SpeedKind}; + let (mut client, gateway) = client_in_world(gameplay_store(GameplayState::default())); + let info = movement_info(0x0220_2001); + for kind in [ + SpeedKind::Walk, + SpeedKind::Run, + SpeedKind::RunBack, + SpeedKind::Swim, + SpeedKind::SwimBack, + SpeedKind::TurnRate, + ] { + client.send( + kind.ack_opcode(), + &messages::force_speed_ack(1, 4, &info, 7.0), + ); + client.ping(u32::from(kind.ack_opcode())); + } + for mode in [ + MoveMode::Root, + MoveMode::WaterWalk, + MoveMode::FeatherFall, + MoveMode::Hover, + ] { + for apply in [true, false] { + client.send( + mode.ack_opcode(apply), + &messages::move_flag_ack(1, 4, &info, mode.ack_carries_apply().then_some(apply)), + ); + client.ping(u32::from(mode.ack_opcode(apply))); + } + } + client.send( + opcode::CMSG_MOVE_KNOCK_BACK_ACK, + &messages::knock_back_ack(1, 4, &info), + ); + client.ping(900); + client.send( + opcode::MSG_MOVE_TELEPORT_ACK, + &messages::teleport_ack(1, 4, 123_456), + ); + client.ping(901); + client.send( + opcode::CMSG_MOVE_SPLINE_DONE, + &messages::move_spline_done(&info, 4), + ); + client.ping(902); + client.logout(); + drop(client); + gateway.join().unwrap().unwrap(); +} + +#[test] +fn benilla_transport_movement_survives_gateway_decode_and_relay() { + for flags in [0x0200_0000, 0x0220_2001, 0x200] { + for guid in [0, 1, 0x0100_0000_0000_0000, u64::MAX] { + let mut info = movement_info(flags); + if let Some(transport) = &mut info.transport { + transport.guid = guid; + } + assert_movement_relay(info); + } + } +} + +#[test] +fn benilla_movement_refuses_truncated_and_trailing_bytes() { + let body = messages::movement(&movement_info(0x0220_2001)); + for end in 0..body.len() { + assert!(crate::codec::read_movement_client( + u32::from(opcode::MSG_MOVE_HEARTBEAT), + &body[..end] + ) + .is_err()); + } + let mut trailing = body.clone(); + trailing.push(0); + assert!( + crate::codec::read_movement_client(u32::from(opcode::MSG_MOVE_HEARTBEAT), &trailing) + .is_err() + ); + assert_eq!( + lyracore_shared::env::fall_time_from_movement_info(&body), + None + ); +} + +fn assert_movement_relay(info: messages::MovementInfo) { + use crate::codec; + let sent = messages::movement(&info); + for &opcode in lyracore_shared::opcodes::movement::SLICE_MOVE_OPCODES { + let decoded = codec::read_movement_client(opcode, &sent).unwrap().unwrap(); + let stored = codec::movement_info_to_bytes(decoded.movement_info().unwrap()).unwrap(); + assert_eq!(stored, sent, "movement flags {:#010x}", info.flags); + let (opcode, body) = + codec::build_movement_relay_raw(opcode, 0x0102_0000_0000_0304, &stored).unwrap(); + assert_movement_packet(decode(opcode, &body), opcode, 0x0102_0000_0000_0304, &info); + } +} + +fn movement_info(flags: u32) -> messages::MovementInfo { + use benilla_protocol::messages::{JumpInfo, MovementInfo, TransportPose}; + use benilla_protocol::wire::Vector3d; + MovementInfo { + flags, + timestamp: 123_456, + position: Vector3d { + x: -100.5, + y: 20.25, + z: 83.5, + }, + orientation: 1.5, + transport: (flags & 0x0200_0000 != 0).then_some(TransportPose { + guid: 0xf120_0000_0000_0012, + pos: Vector3d { + x: 1.0, + y: -2.5, + z: 3.25, + }, + orientation: 0.5, + }), + pitch: if flags & 0x20_0000 != 0 { 0.25 } else { 0.0 }, + fall_time: 2345, + jump: (flags & 0x2000 != 0).then_some(JumpInfo { + zspeed: -7.5, + cos_angle: 0.6, + sin_angle: 0.8, + xy_speed: 7.0, + }), + } +} + +fn assert_movement_packet( + packet: ServerPacket, + expected_opcode: u16, + expected_guid: u64, + info: &messages::MovementInfo, +) { + let ServerPacket::PlayerMove { + guid, + opcode, + flags: actual_flags, + position: actual_pos, + orientation, + pitch, + time, + fall_time, + jump, + transport, + .. + } = packet + else { + panic!("expected a movement relay"); + }; + assert_eq!(guid, expected_guid); + assert_eq!(opcode, expected_opcode); + assert_eq!(actual_flags, info.flags); + assert_eq!(actual_pos, info.position); + assert_eq!(orientation, info.orientation); + assert_eq!(time, info.timestamp); + assert_eq!(fall_time, info.fall_time); + assert_eq!(pitch, info.pitch); + assert_eq!(jump, info.jump); + assert_eq!(transport, info.transport); +} + +#[test] +fn benilla_ticket_refusals_accept_harassment_and_long_text() { + let (mut client, gateway) = client_in_world(gameplay_store(GameplayState::default())); + let text = "a".repeat(500); + let mut body = messages::gm_ticket_create(2, 0, [0.0; 3], &text); + client.send(opcode::CMSG_GMTICKET_CREATE, &body); + assert!(matches!( + client.recv(), + ServerPacket::GmTicketCreated { response: 3 } + )); + // The unavailable service does not decompress or allocate a transcript from its header. + body.extend_from_slice(&1u32.to_le_bytes()); + body.extend_from_slice(&u32::MAX.to_le_bytes()); + body.extend_from_slice(b"opaque transcript"); + client.send(opcode::CMSG_GMTICKET_CREATE, &body); + assert!(matches!( + client.recv(), + ServerPacket::GmTicketCreated { response: 3 } + )); + client.send( + opcode::CMSG_GMTICKET_UPDATETEXT, + &messages::gm_ticket_updatetext(2, &text), + ); + assert!(matches!( + client.recv(), + ServerPacket::GmTicketUpdated { response: 5 } + )); + client.logout(); + drop(client); + gateway.join().unwrap().unwrap(); +} + +#[test] +fn benilla_unavailable_services_return_refusals_and_empty_reads() { + let (mut client, gateway) = client_in_world(gameplay_store(GameplayState::default())); + client.send(opcode::CMSG_GMTICKET_SYSTEMSTATUS, &[]); + assert!(matches!( + client.recv(), + ServerPacket::GmTicketSystemStatus { status: 0 } + )); + client.send(opcode::CMSG_GMTICKET_GETTICKET, &[]); + assert!(matches!( + client.recv(), + ServerPacket::GmTicketAnswer { ticket: None } + )); + client.send( + opcode::CMSG_GMTICKET_CREATE, + &messages::gm_ticket_create(1, 0, [0.0; 3], "Test ticket"), + ); + assert!(matches!( + client.recv(), + ServerPacket::GmTicketCreated { response: 3 } + )); + client.send( + opcode::CMSG_GMTICKET_UPDATETEXT, + &messages::gm_ticket_updatetext(1, "Updated ticket"), + ); + assert!(matches!( + client.recv(), + ServerPacket::GmTicketUpdated { response: 5 } + )); + client.send(opcode::CMSG_GMTICKET_DELETETICKET, &[]); + assert!(matches!( + client.recv(), + ServerPacket::GmTicketDeleted { response: 9 } + )); + for (opcode, body) in [ + ( + opcode::MSG_LIST_STABLED_PETS, + messages::list_stabled_pets(99), + ), + (opcode::CMSG_STABLE_PET, messages::stable_pet(99)), + (opcode::CMSG_UNSTABLE_PET, messages::unstable_pet(99, 1)), + (opcode::CMSG_BUY_STABLE_SLOT, messages::buy_stable_slot(99)), + ( + opcode::CMSG_STABLE_SWAP_PET, + messages::stable_swap_pet(99, 1), + ), + ] { + client.send(opcode, &body); + assert!(matches!( + client.recv(), + ServerPacket::StableResult { result: 6 } + )); + } + for (opcode, body) in [ + (opcode::CMSG_OPEN_ITEM, messages::open_item(255, 23)), + ( + opcode::CMSG_WRAP_ITEM, + messages::wrap_item(255, 23, 255, 24), + ), + ] { + client.send(opcode, &body); + assert!(matches!( + client.recv(), + ServerPacket::InventoryChangeFailure { reason: 39, .. } + )); + } + client.logout(); + drop(client); + gateway.join().unwrap().unwrap(); +} + +#[test] +fn benilla_battleground_queries_report_no_instances_or_queues() { + let (mut client, gateway) = client_in_world(gameplay_store(GameplayState::default())); + client.send( + opcode::CMSG_BATTLEFIELD_LIST, + &messages::battlefield_list(529), + ); + assert!( + matches!(client.recv(), ServerPacket::BattlefieldList(list) if list.map_id == 529 && list.instances.is_empty()) + ); + client.send(opcode::CMSG_BATTLEFIELD_STATUS, &[]); + for slot in 0..3 { + assert!( + matches!(client.recv(), ServerPacket::BattlefieldStatus(status) if status.slot == slot && status.map_id == 0) + ); + } + for (opcode, body) in [ + ( + opcode::CMSG_BATTLEFIELD_JOIN, + messages::battlefield_join(529, 0, false), + ), + ( + opcode::CMSG_BATTLEMASTER_JOIN, + messages::battlemaster_join(99, 529, 0, false), + ), + ( + opcode::CMSG_BATTLEFIELD_PORT, + messages::battlefield_port(529, true), + ), + ] { + client.send(opcode, &body); + assert!(matches!(client.recv(), ServerPacket::MessageChat(_))); + for slot in 0..3 { + assert!( + matches!(client.recv(), ServerPacket::BattlefieldStatus(status) if status.slot == slot && status.map_id == 0) + ); + } + } + client.send( + opcode::CMSG_LEAVE_BATTLEFIELD, + &messages::leave_battlefield(529), + ); + for slot in 0..3 { + assert!( + matches!(client.recv(), ServerPacket::BattlefieldStatus(status) if status.slot == slot && status.map_id == 0) + ); + } + client.send(opcode::MSG_PVP_LOG_DATA, &[]); + assert!( + matches!(client.recv(), ServerPacket::PvpLogData(board) if !board.ended && board.rows.is_empty()) + ); + client.send(opcode::MSG_BATTLEGROUND_PLAYER_POSITIONS, &[]); + assert!( + matches!(client.recv(), ServerPacket::BattlefieldPositions(positions) if positions.players.is_empty() && positions.carrier.is_none()) + ); + client.logout(); + drop(client); + gateway.join().unwrap().unwrap(); +} + +#[test] +fn benilla_missing_gameplay_returns_a_specific_refusal_without_disconnect() { + let (mut client, gateway) = client_in_world(gameplay_store(GameplayState::default())); + let requests = [ + (opcode::CMSG_OPENING_CINEMATIC, vec![]), + ( + opcode::CMSG_SET_LOOKING_FOR_GROUP, + messages::set_looking_for_group([1, 2, 3], "Deadmines"), + ), + ( + opcode::CMSG_PET_SET_ACTION, + messages::pet_set_action(99, &[(0, 1), (1, 0)]), + ), + (opcode::CMSG_PET_ABANDON, messages::pet_abandon(99)), + (opcode::CMSG_PET_RENAME, messages::pet_rename(99, "Wolf")), + (opcode::CMSG_PET_STOP_ATTACK, messages::pet_stop_attack(99)), + ( + opcode::CMSG_PET_CANCEL_AURA, + messages::pet_cancel_aura(99, 1), + ), + (opcode::CMSG_PET_UNLEARN, messages::pet_unlearn(99)), + ( + opcode::CMSG_PET_SPELL_AUTOCAST, + messages::pet_spell_autocast(99, 1, true), + ), + (opcode::CMSG_UNLEARN_SKILL, 164u32.to_le_bytes().to_vec()), + (opcode::CMSG_SET_AMMO, messages::set_ammo(2512)), + ( + opcode::CMSG_STANDSTATECHANGE, + messages::stand_state_change(1), + ), + (opcode::CMSG_MOUNTSPECIAL_ANIM, vec![]), + (opcode::CMSG_TOGGLE_PVP, vec![]), + (opcode::CMSG_TOGGLE_HELM, vec![]), + (opcode::CMSG_TOGGLE_CLOAK, vec![]), + ( + opcode::CMSG_SET_ACTIONBAR_TOGGLES, + messages::set_actionbar_toggles(3), + ), + (opcode::CMSG_TUTORIAL_FLAG, messages::tutorial_flag(15)), + ( + opcode::CMSG_SET_FACTION_INACTIVE, + messages::set_faction_inactive(2, true), + ), + ( + opcode::CMSG_SET_WATCHED_FACTION, + messages::set_watched_faction(-1), + ), + (opcode::CMSG_FAR_SIGHT, vec![1]), + (opcode::CMSG_SUMMON_RESPONSE, messages::summon_response(99)), + ( + opcode::CMSG_QUEST_CONFIRM_ACCEPT, + 1u32.to_le_bytes().to_vec(), + ), + (opcode::CMSG_RESET_INSTANCES, vec![]), + (opcode::CMSG_REQUEST_RAID_INFO, vec![]), + ( + opcode::MSG_INSPECT_HONOR_STATS, + messages::inspect_honor_stats(1), + ), + ]; + for (opcode, body) in requests { + eprintln!("request {opcode:#06x}"); + client.send(opcode, &body); + assert!( + matches!(client.recv(), ServerPacket::MessageChat(_)), + "opcode {opcode:#06x}" + ); + } + client.send( + opcode::CMSG_ACTIVATETAXIEXPRESS, + &messages::activate_taxi_express(99, 100, &[1, 2, 3]), + ); + assert!(matches!( + client.recv(), + ServerPacket::ActivateTaxiReply { code: 1 } + )); + client.logout(); + drop(client); + gateway.join().unwrap().unwrap(); +} + +#[test] +fn benilla_page_queries_and_explicit_purchase_slots_report_missing_support() { + let (mut client, gateway) = client_in_world(gameplay_store(GameplayState::default())); + client.send( + opcode::CMSG_PAGE_TEXT_QUERY, + &messages::page_text_query(333, 99), + ); + assert!( + matches!(client.recv(), ServerPacket::PageTextQueryResponse { page_id: 333, text, next_page_id: 0 } if text.contains("not available")) + ); + client.send( + opcode::CMSG_BUY_ITEM_IN_SLOT, + &messages::buy_item_in_slot(99, 2512, 1, 23, 1), + ); + assert!(matches!( + client.recv(), + ServerPacket::InventoryChangeFailure { reason: 39, .. } + )); + client.logout(); + drop(client); + gateway.join().unwrap().unwrap(); +} + +#[test] +fn benilla_automatic_requests_announce_missing_features_once_per_world_session() { + let (mut client, gateway) = client_in_world(gameplay_store(GameplayState::default())); + for (opcode, body) in [ + ( + opcode::CMSG_STANDSTATECHANGE, + messages::stand_state_change(1), + ), + ( + opcode::CMSG_SET_ACTIONBAR_TOGGLES, + messages::set_actionbar_toggles(3), + ), + (opcode::CMSG_TUTORIAL_FLAG, messages::tutorial_flag(15)), + ] { + client.send(opcode, &body); + assert!(matches!(client.recv(), ServerPacket::MessageChat(_))); + client.send(opcode, &body); + client.ping(u32::from(opcode)); + } + for opcode in [opcode::CMSG_TUTORIAL_CLEAR, opcode::CMSG_TUTORIAL_RESET] { + client.send(opcode, &[]); + client.ping(u32::from(opcode)); + } + client.logout(); + drop(client); + gateway.join().unwrap().unwrap(); +} + +#[test] +fn benilla_connection_receipts_and_forced_logout_keep_character_selection_usable() { + let (mut client, gateway) = client_in_world(gameplay_store(GameplayState::default())); + for opcode in [ + opcode::CMSG_NEXT_CINEMATIC_CAMERA, + opcode::CMSG_COMPLETE_CINEMATIC, + ] { + client.send(opcode, &[]); + client.ping(u32::from(opcode)); + } + client.send(opcode::CMSG_SET_ACTIVE_MOVER, &messages::full_guid(1)); + client.ping(1); + client.send(opcode::CMSG_LOGOUT_CANCEL, &[]); + assert!(matches!(client.recv(), ServerPacket::LogoutCancelAck)); + client.send(opcode::CMSG_PLAYER_LOGOUT, &[]); + assert!(matches!( + client.recv(), + ServerPacket::LogoutResponse { reason: 0, .. } + )); + assert!(matches!(client.recv(), ServerPacket::LogoutComplete)); + client.enter_world(); + client.query_clock(); + client.logout(); + drop(client); + gateway.join().unwrap().unwrap(); +} + +#[test] +fn benilla_bind_and_respec_confirmations_reach_existing_gameplay() { + let mut store = gameplay_store(GameplayState::default()); + Arc::get_mut(&mut store).unwrap().npc.innkeeper = true; + let (mut client, gateway) = client_in_world(store.clone()); + client.send(opcode::CMSG_GOSSIP_HELLO, &messages::gossip_hello(99)); + assert!(matches!(client.recv(), ServerPacket::GossipMessage { .. })); + client.send( + opcode::CMSG_GOSSIP_SELECT_OPTION, + &messages::gossip_select_option(99, 0, None), + ); + assert!(matches!(client.recv(), ServerPacket::GossipComplete)); + assert!(matches!( + client.recv(), + ServerPacket::BinderConfirm { binder: 99 } + )); + assert!(!store + .npc + .home_bound + .load(std::sync::atomic::Ordering::SeqCst)); + client.send(opcode::CMSG_BINDER_ACTIVATE, &messages::binder_activate(99)); + assert!(matches!(client.recv(), ServerPacket::GossipComplete)); + assert!(store + .npc + .home_bound + .load(std::sync::atomic::Ordering::SeqCst)); + client.send( + opcode::MSG_TALENT_WIPE_CONFIRM, + &messages::talent_wipe_confirm(99), + ); + assert!(matches!(client.recv(), ServerPacket::GossipComplete)); + assert_eq!( + *store.trainer.reset_talents_calls.lock().unwrap(), + [(7, 1, 99)] + ); + client.logout(); + drop(client); + gateway.join().unwrap().unwrap(); +} + +#[test] +fn benilla_missing_talent_quote_reports_unavailability() { + let mut store = gameplay_store(GameplayState::default()); + let setup = Arc::get_mut(&mut store).unwrap(); + setup.characters[0].level = 10; + setup.trainer.talent_reset_quote = None; + setup.npc.gossip_opts = vec![super::opt( + 0, + "Reset talents", + lyracore_shared::constants::gossip_option::UNLEARNTALENTS, + )]; + let (mut client, gateway) = client_in_world(store.clone()); + client.send(opcode::CMSG_GOSSIP_HELLO, &messages::gossip_hello(99)); + assert!(matches!(client.recv(), ServerPacket::GossipMessage { .. })); + client.send( + opcode::CMSG_GOSSIP_SELECT_OPTION, + &messages::gossip_select_option(99, 0, None), + ); + assert!(matches!(client.recv(), ServerPacket::GossipComplete)); + assert!(matches!(client.recv(), ServerPacket::MessageChat(_))); + assert!(store.trainer.reset_talents_calls.lock().unwrap().is_empty()); + client.logout(); + drop(client); + gateway.join().unwrap().unwrap(); +} + +#[test] +fn benilla_at_war_uses_a_four_byte_reputation_index_and_one_byte_boolean() { + let store = gameplay_store(GameplayState::default()); + let (mut client, gateway) = client_in_world(store.clone()); + client.send( + opcode::CMSG_SET_FACTION_ATWAR, + &messages::set_faction_at_war(0, true), + ); + client.ping(1); + client.send( + opcode::CMSG_SET_FACTION_ATWAR, + &messages::set_faction_at_war(63, false), + ); + client.ping(2); + assert_eq!( + *store.trainer.reputation_at_war.lock().unwrap(), + [(0, true), (63, false)].into_iter().collect() + ); + client.logout(); + drop(client); + gateway.join().unwrap().unwrap(); +} + +#[test] +fn benilla_compatibility_requests_refuse_truncation_and_trailing_bytes() { + for (opcode, valid) in [ + ( + opcode::CMSG_BATTLEFIELD_JOIN, + messages::battlefield_join(529, 0, false), + ), + ( + opcode::CMSG_PAGE_TEXT_QUERY, + messages::page_text_query(1, 99), + ), + ( + opcode::CMSG_SET_FACTION_ATWAR, + messages::set_faction_at_war(0, true), + ), + ( + opcode::CMSG_SET_FACTION_INACTIVE, + messages::set_faction_inactive(0, true), + ), + ( + opcode::CMSG_SET_WATCHED_FACTION, + messages::set_watched_faction(-1), + ), + ( + opcode::CMSG_ACTIVATETAXIEXPRESS, + messages::activate_taxi_express(99, 10, &[1, 2]), + ), + ( + opcode::CMSG_SET_LOOKING_FOR_GROUP, + messages::set_looking_for_group([0; 3], "test"), + ), + (opcode::CMSG_OPENING_CINEMATIC, vec![]), + ] { + let mut bodies = vec![[valid.as_slice(), &[0]].concat()]; + if !valid.is_empty() { + bodies.push(valid[..valid.len() - 1].to_vec()); + } + for body in bodies { + let (mut client, gateway) = client_in_world(gameplay_store(GameplayState::default())); + client.send(opcode, &body); + assert_eq!( + client.socket.read(&mut [0; 1]).unwrap(), + 0, + "opcode {opcode:#06x}" + ); + drop(client); + assert!(gateway.join().unwrap().is_err()); + } + } +} diff --git a/gateway/src/world/benilla_tests/durable.rs b/gateway/src/world/benilla_tests/durable.rs new file mode 100644 index 000000000..bf842609b --- /dev/null +++ b/gateway/src/world/benilla_tests/durable.rs @@ -0,0 +1,828 @@ +//! Benilla packets across the Gateway and a private Module instance. + +use super::*; +use crate::accept::BlockingTaskCapacity; +use crate::config::GatewayConfig; +use crate::durable_test_support::Standalone; +use crate::stdb::Coordinator; +use std::time::{Duration, Instant}; + +struct Realm { + coordinator: Coordinator, + runtime: tokio::runtime::Runtime, + standalone: Standalone, +} + +impl Realm { + fn start(name: &str) -> Self { + for variable in [ + "LYRACORE_SHARD_MAP", + "LYRACORE_SHARD_MAP_FILE", + "LYRACORE_REALM_CORE", + ] { + assert!(std::env::var_os(variable).is_none(), "unset {variable}"); + } + let mut standalone = Standalone::start(name); + standalone.publish_module(); + standalone.assert_call("claim_operator", &[]); + standalone.assert_call("install_guid_range", &["0"]); + standalone.assert_call("gw_heartbeat", &[]); + let cfg = GatewayConfig { + logon_bind: "127.0.0.1:0".into(), + world_bind: "127.0.0.1:0".into(), + stdb_uri: standalone.server().into(), + module_name: standalone.shard_name().into(), + coordinator_token: Some(standalone.owner_token()), + gateway_id: name.into(), + blocking_task_capacity: BlockingTaskCapacity::new(4), + }; + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + .unwrap(); + let coordinator = runtime.block_on(Coordinator::connect(&cfg)).unwrap(); + { + let _entered = runtime.enter(); + coordinator.spawn_gateway_heartbeat(); + } + Self { + coordinator, + runtime, + standalone, + } + } + + fn connect(&self, username: &str) -> (Client, std::thread::JoinHandle>) { + let account = self + .coordinator + .account_by_username(username) + .unwrap() + .unwrap() + .id; + self.coordinator + .establish_session( + account, + &[7; 40], + self.coordinator.bound_identity(account).unwrap(), + ) + .unwrap(); + let (socket, gateway_socket) = world_session_socket_pair(); + let coordinator = self.coordinator.clone(); + let runtime = self.runtime.handle().clone(); + let gateway = std::thread::spawn(move || { + let _entered = runtime.enter(); + run_world_session(gateway_socket, Arc::new(coordinator)) + }); + (Client::connect_named(socket, [7; 40], username), gateway) + } + + fn create_observer(&self) -> u64 { + self.coordinator + .provision_account("OBSERVER", &[0; 32], &[0; 32]) + .unwrap(); + let account = self + .coordinator + .account_by_username("OBSERVER") + .unwrap() + .unwrap() + .id; + assert_eq!( + self.coordinator + .create_character( + account, + "Observer", + 1, + 1, + 0, + crate::codec::Appearance::default(), + ) + .unwrap(), + crate::codec::CharCreateOutcome::Success + ); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if let Some(character) = self.coordinator.characters(account).unwrap().first() { + return character.guid; + } + assert!( + Instant::now() < deadline, + "Character projection did not arrive" + ); + std::thread::sleep(Duration::from_millis(10)); + } + } +} + +impl Client { + fn fields(&self, guid: u64) -> messages::ObjectFields { + messages::ObjectFields::from_pairs( + &self + .objects + .get(&guid) + .unwrap() + .iter() + .map(|(&field, &value)| (field, value)) + .collect::>(), + ) + } + + fn created_entry(&mut self, entry: u32) -> u64 { + let ServerPacket::UpdateObject { objects } = self.until("creature creation", |packet| { + matches!(packet, ServerPacket::UpdateObject { objects } if objects.iter().any(|object| + matches!(object, Object::Create { mask, .. } if mask.object_entry() == Some(entry)))) + }) else { unreachable!() }; + objects + .into_iter() + .find_map(|object| match object { + Object::Create { guid, mask, .. } if mask.object_entry() == Some(entry) => { + Some(guid) + } + _ => None, + }) + .unwrap() + } + + fn until_view(&mut self, description: &str, matches: impl Fn(&Self) -> bool) { + let deadline = Instant::now() + Duration::from_secs(15); + while !matches(self) { + assert!(Instant::now() < deadline, "waiting for {description}"); + if let ServerPacket::InventoryChangeFailure { reason, .. } = self.recv() { + panic!("waiting for {description}: inventory Refusal {reason}"); + } + } + } + + fn item_stacks(&self, entry: u32) -> Vec { + let mut stacks: Vec<_> = self + .objects + .values() + .filter(|fields| fields.get(&3) == Some(&entry)) + .map(|fields| fields.get(&14).copied().unwrap_or(0)) + .collect(); + stacks.sort_unstable(); + stacks + } + + fn backpack_slot(&self, entry: u32) -> Option { + let owner = self.fields(1); + (0..16).find_map(|slot| { + let guid = owner.player_pack_slot(slot)?; + (self.objects.get(&guid)?.get(&3) == Some(&entry)).then_some(slot + 23) + }) + } + + fn until( + &mut self, + description: &str, + mut matches: impl FnMut(&ServerPacket) -> bool, + ) -> ServerPacket { + let deadline = Instant::now() + Duration::from_secs(15); + let mut seen = Vec::new(); + loop { + assert!( + Instant::now() < deadline, + "waiting for {description}; saw {seen:?}" + ); + let packet = self.recv(); + if matches(&packet) { + return packet; + } + seen.push(packet.name()); + } + } + + fn peer(&mut self, guid: u64) -> messages::MovementBlock { + let ServerPacket::UpdateObject { objects } = self.until("peer creation", |packet| { + matches!(packet, ServerPacket::UpdateObject { objects } if objects.iter().any(|object| + matches!(object, Object::Create { guid: actual, .. } if *actual == guid))) + }) else { + unreachable!() + }; + objects + .into_iter() + .find_map(|object| match object { + Object::Create { + guid: actual, + movement, + .. + } if actual == guid => Some(movement), + _ => None, + }) + .unwrap() + } + + fn finish(mut self) { + self.send(opcode::CMSG_LOGOUT_REQUEST, &[]); + self.until("logout complete", |packet| { + matches!(packet, ServerPacket::LogoutComplete) + }); + } +} + +#[test] +#[ignore = "requires SpacetimeDB 2.7.1 and the Wasm toolchain"] +fn benilla_durable_kill_quest_loot_rewards_and_reconnect() { + const QUEST: u32 = 50900; + const WOLF: u32 = 51000; + const JERKY: u32 = 5_090_052; + let realm = Realm::start("benilla-gameplay"); + realm + .standalone + .assert_call("debug_seed_scenario_fixtures", &[]); + realm.standalone.assert_sql("UPDATE game_creature_template SET health = 1, faction_template = 14, aggro_range = 0, money_min = 25, money_max = 25 WHERE entry = 51000"); + realm.standalone.assert_sql("INSERT INTO game_creature_loot (id, creature_entry, item_entry, chance_bp, count, group_id, quest_only) VALUES (5090099, 51000, 5090052, 10000, 3, 0, false)"); + let (mut client, gateway) = realm.connect("TEST"); + client.enter_world(); + client.query_clock(); + realm + .standalone + .assert_call("debug_spawn_at_feet", &["1", "51003", "1"]); + let giver = client.created_entry(51003); + client.send( + opcode::CMSG_QUESTGIVER_ACCEPT_QUEST, + &messages::questgiver_accept_quest(giver, QUEST), + ); + client.until_view("accepted quest", |client| { + client + .fields(1) + .player_quest_log(0) + .is_some_and(|q| q.quest_id == QUEST) + }); + for count in 1..=2 { + realm + .standalone + .assert_call("debug_spawn_at_feet", &["1", "51000", "2"]); + let wolf = client.created_entry(WOLF); + client.send(opcode::CMSG_ATTACKSWING, &messages::attack_swing(wolf)); + client.until("quest kill credit", |packet| matches!(packet, + ServerPacket::QuestUpdateAddKill { quest_id: QUEST, entry: WOLF, count: actual, required: 2, .. } if *actual == count)); + client.until_view("slain creature", |client| { + client.fields(wolf).unit_health() == Some(0) + }); + client.send(opcode::CMSG_ATTACKSTOP, &[]); + client.send(opcode::CMSG_LOOT, &messages::loot(wolf)); + let ServerPacket::LootResponse { gold, items, .. } = client.until( + "loot window", + |packet| matches!(packet, ServerPacket::LootResponse { guid, .. } if *guid == wolf), + ) else { + unreachable!() + }; + assert_eq!(gold, 25); + let item = items.iter().find(|item| item.item_id == JERKY).unwrap(); + assert_eq!(item.count, 3); + client.send(opcode::CMSG_LOOT_MONEY, &messages::loot_money()); + client.send( + opcode::CMSG_AUTOSTORE_LOOT_ITEM, + &messages::autostore_loot_item(item.slot), + ); + client.until_view("looted money and item", |client| { + client.fields(1).player_money() == Some(count * 25) + && client.item_stacks(JERKY) == [count * 3] + }); + client.send(opcode::CMSG_LOOT_RELEASE, &messages::loot_release(wolf)); + client.until("loot closed", |packet| matches!(packet, ServerPacket::LootReleaseResponse { guid, .. } if *guid == wolf)); + } + client.send( + opcode::CMSG_QUESTGIVER_REQUEST_REWARD, + &messages::questgiver_request_reward(giver, QUEST), + ); + client.until("quest reward screen", |packet| matches!(packet, ServerPacket::QuestGiverOfferReward(offer) if offer.quest_id == QUEST)); + client.send( + opcode::CMSG_QUESTGIVER_CHOOSE_REWARD, + &messages::questgiver_choose_reward(giver, QUEST, 0), + ); + let ServerPacket::QuestGiverComplete(reward) = client.until("quest completion", |packet| + matches!(packet, ServerPacket::QuestGiverComplete(reward) if reward.quest_id == QUEST)) else { unreachable!() }; + assert_eq!((reward.money, reward.xp), (150, 90)); + client.until_view("quest rewards", |client| { + client.fields(1).player_money() == Some(200) && client.item_stacks(JERKY) == [8] + }); + let xp = client.fields(1).player_xp().unwrap(); + client.finish(); + gateway.join().unwrap().unwrap(); + let (mut client, gateway) = realm.connect("TEST"); + client.enter_world(); + assert_eq!(client.fields(1).player_money(), Some(200)); + assert_eq!(client.fields(1).player_xp(), Some(xp)); + assert_eq!(client.item_stacks(JERKY), [8]); + client.finish(); + gateway.join().unwrap().unwrap(); +} + +#[test] +#[ignore = "requires SpacetimeDB 2.7.1 and the Wasm toolchain"] +fn benilla_durable_inventory_split_updates_both_stacks_and_survives_reconnect() { + const JERKY: u32 = 5_090_052; + let realm = Realm::start("benilla-inventory"); + let (mut client, gateway) = realm.connect("TEST"); + client.enter_world(); + realm + .standalone + .assert_call("debug_grant_item", &["1", &JERKY.to_string(), "10"]); + client.until_view("granted stack", |client| { + client.item_stacks(JERKY) == [10] && client.backpack_slot(JERKY).is_some() + }); + let source = client.backpack_slot(JERKY).unwrap(); + let destination = (0..16) + .find(|slot| client.fields(1).player_pack_slot(*slot).unwrap_or(0) == 0) + .unwrap() + + 23; + for (slot, count) in [ + (0, 3), + (19, 3), + (source, 3), + (destination, 0), + (destination, 10), + ] { + client.send( + opcode::CMSG_SPLIT_ITEM, + &messages::split_item(255, source, 255, slot, count), + ); + client.until("invalid split Refusal", |packet| { + matches!( + packet, + ServerPacket::InventoryChangeFailure { reason: 3, .. } + ) + }); + assert_eq!(client.item_stacks(JERKY), [10]); + } + client.send( + opcode::CMSG_SPLIT_ITEM, + &messages::split_item(255, source, 255, destination, 3), + ); + client.until_view("split stacks", |client| client.item_stacks(JERKY) == [3, 7]); + client.finish(); + gateway.join().unwrap().unwrap(); + let (mut client, gateway) = realm.connect("TEST"); + client.enter_world(); + assert_eq!(client.item_stacks(JERKY), [3, 7]); + client.finish(); + gateway.join().unwrap().unwrap(); +} + +#[test] +#[ignore = "requires SpacetimeDB 2.7.1 and the Wasm toolchain"] +fn benilla_durable_destroy_item_updates_the_stack_and_preserves_indestructible_items() { + const JERKY: u32 = 5_090_052; + let realm = Realm::start("benilla-destroy-item"); + let (mut client, gateway) = realm.connect("TEST"); + client.enter_world(); + realm + .standalone + .assert_call("debug_grant_item", &["1", &JERKY.to_string(), "10"]); + client.until_view("granted stack", |client| { + client.item_stacks(JERKY) == [10] && client.backpack_slot(JERKY).is_some() + }); + let slot = client.backpack_slot(JERKY).unwrap(); + client.send( + opcode::CMSG_DESTROYITEM, + &messages::destroy_item(255, slot, 3), + ); + client.until_view("partial destruction", |client| { + client.item_stacks(JERKY) == [7] + }); + realm + .standalone + .assert_sql("UPDATE game_item_template SET item_flags = 32 WHERE entry = 5090052"); + client.send( + opcode::CMSG_DESTROYITEM, + &messages::destroy_item(255, slot, 0), + ); + client.until("indestructible Refusal", |packet| { + matches!( + packet, + ServerPacket::InventoryChangeFailure { reason: 24, .. } + ) + }); + assert_eq!(client.item_stacks(JERKY), [7]); + realm + .standalone + .assert_sql("UPDATE game_item_template SET item_flags = 0 WHERE entry = 5090052"); + client.send( + opcode::CMSG_DESTROYITEM, + &messages::destroy_item(255, slot, 0), + ); + client.until_view("whole stack destruction", |client| { + client.item_stacks(JERKY).is_empty() + && client.fields(1).player_pack_slot(slot - 23) == Some(0) + }); + client.finish(); + gateway.join().unwrap().unwrap(); + let (mut client, gateway) = realm.connect("TEST"); + client.enter_world(); + assert!(client.item_stacks(JERKY).is_empty()); + client.finish(); + gateway.join().unwrap().unwrap(); +} + +#[test] +#[ignore = "requires SpacetimeDB 2.7.1 and the Wasm toolchain"] +fn benilla_durable_destroying_equipped_weapon_updates_the_character_sheet() { + const BLADE: u32 = 5_090_050; + let realm = Realm::start("benilla-destroy-equipped"); + realm.standalone.assert_sql( + "UPDATE game_item_template SET damage_min = 100, damage_max = 150 WHERE entry = 5090050", + ); + let (mut client, gateway) = realm.connect("TEST"); + client.enter_world(); + realm + .standalone + .assert_call("debug_grant_item", &["1", &BLADE.to_string(), "1"]); + client.until_view("blade granted", |client| { + client.backpack_slot(BLADE).is_some() + }); + client.send( + opcode::CMSG_AUTOEQUIP_ITEM, + &messages::auto_equip_item(255, client.backpack_slot(BLADE).unwrap()), + ); + client.until_view("blade equipped", |client| { + client + .fields(1) + .player_inv_slot(15) + .is_some_and(|guid| client.fields(guid).object_entry() == Some(BLADE)) + }); + let weapon = client.fields(1).player_inv_slot(15).unwrap(); + assert_ne!(weapon, 0); + let damage = || { + realm + .standalone + .query_rows("SELECT sheet_dmg_min FROM game_world_entity WHERE guid = 1")[0] + ["sheet_dmg_min"] + .parse::() + .unwrap() + }; + let armed = damage(); + assert!(armed >= 100.0, "equipped blade damage is {armed}"); + client.send( + opcode::CMSG_DESTROYITEM, + &messages::destroy_item(255, 15, 0), + ); + client.until_view("equipped weapon destruction", |client| { + !client.objects.contains_key(&weapon) && client.fields(1).player_inv_slot(15) == Some(0) + }); + let unarmed = damage(); + assert!(unarmed < 100.0, "unarmed damage is {unarmed}, was {armed}"); + client.finish(); + gateway.join().unwrap().unwrap(); +} + +#[test] +#[ignore = "requires SpacetimeDB 2.7.1 and the Wasm toolchain"] +fn benilla_durable_bag_contents_remain_addressable_after_reconnect() { + const JERKY: u32 = 5_090_052; + const BAG: u32 = 5_090_054; + let realm = Realm::start("benilla-bags"); + realm.standalone.assert_sql("UPDATE game_item_template SET class = 1, inventory_type = 18, container_slots = 4, required_level = 0 WHERE entry = 5090054"); + let (mut client, gateway) = realm.connect("TEST"); + client.enter_world(); + for (entry, count) in [(BAG, 1), (JERKY, 10)] { + realm.standalone.assert_call( + "debug_grant_item", + &["1", &entry.to_string(), &count.to_string()], + ); + client.until_view("granted item", |client| { + client.backpack_slot(entry).is_some() + }); + } + client.send( + opcode::CMSG_AUTOEQUIP_ITEM, + &messages::auto_equip_item(255, client.backpack_slot(BAG).unwrap()), + ); + client.until_view("equipped bag", |client| { + client.fields(1).player_inv_slot(19).unwrap_or(0) != 0 + }); + let bag_guid = client.fields(1).player_inv_slot(19).unwrap(); + assert_eq!(client.fields(bag_guid).container_num_slots(), Some(4)); + let source = client.backpack_slot(JERKY).unwrap(); + client.send( + opcode::CMSG_SPLIT_ITEM, + &messages::split_item(255, source, 19, 0, 3), + ); + client.until_view("split into bag", |client| { + client.item_stacks(JERKY) == [3, 7] + && client.fields(bag_guid).container_slot(0).unwrap_or(0) != 0 + }); + for (bag, slot) in [(19, 4), (22, 0), (255, 120)] { + client.send( + opcode::CMSG_SPLIT_ITEM, + &messages::split_item(255, source, bag, slot, 1), + ); + client.until("invalid bag slot Refusal", |packet| { + matches!( + packet, + ServerPacket::InventoryChangeFailure { reason: 3, .. } + ) + }); + assert_eq!(client.item_stacks(JERKY), [3, 7]); + } + client.send( + opcode::CMSG_DESTROYITEM, + &messages::destroy_item(255, 19, 0), + ); + client.until("nonempty bag Refusal", |packet| { + matches!( + packet, + ServerPacket::InventoryChangeFailure { reason: 31, .. } + ) + }); + client.send( + opcode::CMSG_SWAP_ITEM, + &messages::swap_item(255, 30, 255, 19), + ); + client.until("moving a nonempty bag is refused", |packet| { + matches!( + packet, + ServerPacket::InventoryChangeFailure { reason: 31, .. } + ) + }); + client.send( + opcode::CMSG_SWAP_ITEM, + &messages::swap_item(255, 20, 255, source), + ); + client.until("only bags fit bag equipment slots", |packet| { + matches!(packet, ServerPacket::InventoryChangeFailure { .. }) + }); + client.send( + opcode::CMSG_SWAP_ITEM, + &messages::swap_item(255, source, 255, 15), + ); + client.until("reverse swap cannot equip food", |packet| { + matches!(packet, ServerPacket::InventoryChangeFailure { .. }) + }); + assert_eq!(client.fields(1).player_inv_slot(19), Some(bag_guid)); + assert_eq!(client.item_stacks(JERKY), [3, 7]); + client.finish(); + gateway.join().unwrap().unwrap(); + let (mut client, gateway) = realm.connect("TEST"); + client.enter_world(); + client.until_view("reconnected bag contents", |client| { + client.fields(bag_guid).container_slot(0).unwrap_or(0) != 0 + }); + assert_eq!(client.item_stacks(JERKY), [3, 7]); + client.send(opcode::CMSG_SWAP_ITEM, &messages::swap_item(19, 1, 19, 0)); + client.until_view("move within bag", |client| { + client.fields(bag_guid).container_slot(0) == Some(0) + && client.fields(bag_guid).container_slot(1).unwrap_or(0) != 0 + }); + const BLADE: u32 = 5_090_050; + realm + .standalone + .assert_call("debug_grant_item", &["1", &BLADE.to_string(), "1"]); + client.until_view("blade granted", |client| { + client.backpack_slot(BLADE).is_some() + }); + client.send( + opcode::CMSG_SWAP_ITEM, + &messages::swap_item(19, 0, 255, client.backpack_slot(BLADE).unwrap()), + ); + client.until_view("blade stored in bag", |client| { + client.fields(bag_guid).container_slot(0).unwrap_or(0) != 0 + }); + let blade_guid = client.fields(bag_guid).container_slot(0).unwrap(); + client.send( + opcode::CMSG_AUTOEQUIP_ITEM, + &messages::auto_equip_item(19, 0), + ); + client.until_view("blade equipped from bag", |client| { + client.fields(1).player_inv_slot(15) == Some(blade_guid) + }); + client.send(opcode::CMSG_DESTROYITEM, &messages::destroy_item(19, 0, 0)); + client.until_view("replaced weapon removed", |client| { + client.fields(bag_guid).container_slot(0) == Some(0) + }); + realm + .standalone + .assert_sql("UPDATE game_world_entity SET health = 40 WHERE guid = 1"); + client.until_view("injured Character", |client| { + client.fields(1).unit_health() == Some(40) + }); + client.send( + opcode::CMSG_USE_ITEM, + &messages::use_item(19, 1, 0, messages::UseItemTarget::SelfImplicit), + ); + client.until_view("food consumed from bag", |client| { + client.item_stacks(JERKY) == [2, 7] + }); + client.send(opcode::CMSG_DESTROYITEM, &messages::destroy_item(19, 1, 0)); + client.until_view("bag item destruction", |client| { + client.item_stacks(JERKY) == [7] && client.fields(bag_guid).container_slot(1) == Some(0) + }); + client.send( + opcode::CMSG_DESTROYITEM, + &messages::destroy_item(255, 19, 0), + ); + client.until_view("empty bag destruction", |client| { + !client.objects.contains_key(&bag_guid) && client.fields(1).player_inv_slot(19) == Some(0) + }); + client.finish(); + gateway.join().unwrap().unwrap(); +} + +#[test] +#[ignore = "requires SpacetimeDB 2.7.1 and the Wasm toolchain"] +fn benilla_durable_cancel_channelling_stops_the_active_channel() { + let realm = Realm::start("benilla-channel-cancel"); + realm.standalone.assert_sql( + "UPDATE game_spell SET cast_flags = 128, duration_ms = 60000 WHERE spell_id = 1120", + ); + realm + .standalone + .assert_sql("UPDATE game_creature_template SET health = 100000 WHERE entry = 51000"); + let (mut client, gateway) = realm.connect("TEST"); + client.enter_world(); + realm + .standalone + .assert_call("debug_learn_spell", &["1", "1120"]); + realm + .standalone + .assert_call("debug_spawn_at_feet", &["1", "51000", "2"]); + let target = client.created_entry(51000); + realm.standalone.assert_sql(&format!( + "UPDATE game_world_entity SET health = 100000, max_health = 100000 WHERE guid = {target}" + )); + client.until_view("channel target health", |client| { + client.fields(target).unit_health() == Some(100000) + }); + client.send( + opcode::CMSG_CAST_SPELL, + &messages::cast_spell(1120, Some(target)), + ); + client.until("active channel", |packet| { + matches!( + packet, + ServerPacket::ChannelStart { + spell_id: 1120, + duration_ms: 60000 + } + ) + }); + let cancelled_at = Instant::now(); + client.send(opcode::CMSG_CANCEL_CHANNELLING, &1120u32.to_le_bytes()); + client.until("cancelled channel", |packet| { + matches!(packet, ServerPacket::ChannelUpdate { remaining_ms: 0 }) + }); + assert!( + cancelled_at.elapsed() < Duration::from_secs(2), + "channel ended without prompt cancellation" + ); + client.finish(); + gateway.join().unwrap().unwrap(); +} + +#[test] +#[ignore = "requires SpacetimeDB 2.7.1 and the Wasm toolchain"] +fn benilla_durable_peer_discovery_movement_and_aoi() { + let realm = Realm::start("benilla-movement"); + let observer_guid = realm.create_observer(); + let (mut first, first_gateway) = realm.connect("TEST"); + first.enter_world(); + let (mut second, second_gateway) = realm.connect("OBSERVER"); + second.enter_world_as(observer_guid, "Observer"); + second.peer(1); + first.peer(observer_guid); + + let mut movement = movement_info(1); + movement.position.x = -8948.0; + movement.position.y = -132.0; + first.send( + opcode::MSG_MOVE_START_FORWARD, + &messages::movement(&movement), + ); + let packet = second.until("peer movement", |packet| { + matches!(packet, ServerPacket::PlayerMove { guid: 1, .. }) + }); + assert_movement_packet(packet, opcode::MSG_MOVE_START_FORWARD, 1, &movement); + + let mut transported = movement_info(0x0200_0001); + transported.position = movement.position; + transported.timestamp += 100; + first.send( + opcode::MSG_MOVE_HEARTBEAT, + &messages::movement(&transported), + ); + let packet = second.until("transport movement", |packet| { + matches!(packet, + ServerPacket::PlayerMove { guid: 1, time, .. } if *time == transported.timestamp) + }); + assert_movement_packet(packet, opcode::MSG_MOVE_HEARTBEAT, 1, &transported); + movement = transported; + + second.finish(); + second_gateway.join().unwrap().unwrap(); + let (mut late, late_gateway) = realm.connect("OBSERVER"); + late.enter_world_as(observer_guid, "Observer"); + let created = late.peer(1); + assert_eq!(created.position.unwrap().0, movement.position); + assert_eq!(created.mover.unwrap().flags & 1, 1); + assert_eq!(created.transport, movement.transport); + + movement.position.x += 1000.0; + movement.timestamp += 100; + movement.flags = 0; + movement.transport = None; + first.send(opcode::MSG_MOVE_STOP, &messages::movement(&movement)); + late.until("peer leaving the AOI", |packet| match packet { + ServerPacket::DestroyObject { guid: 1 } => true, + ServerPacket::UpdateObject { objects } => objects + .iter() + .any(|object| matches!(object, Object::OutOfRange { guids } if guids.contains(&1))), + _ => false, + }); + movement.position.x -= 1000.0; + movement.timestamp += 100; + first.send(opcode::MSG_MOVE_STOP, &messages::movement(&movement)); + assert_eq!(late.peer(1).position.unwrap().0, movement.position); + first.finish(); + late.finish(); + first_gateway.join().unwrap().unwrap(); + late_gateway.join().unwrap().unwrap(); +} + +#[test] +#[ignore = "requires SpacetimeDB 2.7.1 and the Wasm toolchain"] +fn benilla_durable_binding_refreshes_the_hearth_point_immediately() { + let realm = Realm::start("benilla-bind"); + realm + .standalone + .assert_call("debug_seed_scenario_fixtures", &[]); + realm + .standalone + .assert_sql("UPDATE game_character SET home_x = 0 WHERE guid = 1"); + realm + .standalone + .assert_sql("UPDATE game_creature_template SET npc_flags = 128 WHERE entry = 51003"); + let (mut client, gateway) = realm.connect("TEST"); + client.enter_world(); + realm + .standalone + .assert_call("debug_spawn_at_feet", &["1", "51003", "2"]); + let binder = client.created_entry(51003); + let character = realm.coordinator.character_by_guid(1).unwrap().unwrap(); + realm.standalone.assert_sql(&format!( + "UPDATE game_world_entity SET npc_flags = 128, x = {}, y = {}, z = {} WHERE guid = {binder}", + character.x, character.y, character.z, + )); + client.until_view("innkeeper flags", |client| { + client.fields(binder).unit_npc_flags() == 128 + }); + realm + .standalone + .assert_call("debug_spawn_at_feet", &["1", "51003", "100"]); + let distant = realm.standalone.query_rows(&format!( + "SELECT guid FROM game_world_entity WHERE entry = 51003 AND guid != {binder}" + ))[0]["guid"] + .parse::() + .unwrap(); + client.send( + opcode::CMSG_BINDER_ACTIVATE, + &messages::binder_activate(distant), + ); + client.until("distant innkeeper refusal", |packet| { + matches!(packet, ServerPacket::MessageChat(_)) + }); + client.until("refused bind complete", |packet| { + matches!(packet, ServerPacket::GossipComplete) + }); + assert_eq!( + realm + .standalone + .query_rows("SELECT home_x FROM game_character WHERE guid = 1")[0]["home_x"], + "0" + ); + for change in ["npc_flags = 0", "npc_flags = 128, dead = true"] { + realm.standalone.assert_sql(&format!( + "UPDATE game_world_entity SET {change} WHERE guid = {binder}" + )); + client.send( + opcode::CMSG_BINDER_ACTIVATE, + &messages::binder_activate(binder), + ); + client.until("unavailable innkeeper refusal", |packet| { + matches!(packet, ServerPacket::MessageChat(_)) + }); + client.until("refused bind complete", |packet| { + matches!(packet, ServerPacket::GossipComplete) + }); + assert_eq!( + realm + .standalone + .query_rows("SELECT home_x FROM game_character WHERE guid = 1")[0]["home_x"], + "0" + ); + } + realm.standalone.assert_sql(&format!( + "UPDATE game_world_entity SET npc_flags = 128, dead = false WHERE guid = {binder}" + )); + client.query_clock(); + client.send( + opcode::CMSG_BINDER_ACTIVATE, + &messages::binder_activate(binder), + ); + client.until("updated hearth point", |packet| { + matches!(packet, ServerPacket::BindPoint { position, map, .. } + if position.x == character.x && position.y == character.y && position.z == character.z && *map == character.map_id) + }); + client.finish(); + gateway.join().unwrap().unwrap(); +} diff --git a/gateway/src/world/death_tests.rs b/gateway/src/world/death_tests.rs index 39e4700df..ef61a5547 100644 --- a/gateway/src/world/death_tests.rs +++ b/gateway/src/world/death_tests.rs @@ -27,6 +27,7 @@ pub(crate) fn in_world_conn() -> WorldConn { }), move_coalesce: Default::default(), gossip_menu: None, + unavailable_notices: Default::default(), store: RoutedStore::new(std::sync::Arc::new(WorldFake::default())), session_key: None, guild_signed_on: None, diff --git a/gateway/src/world/handle_loot_fake.rs b/gateway/src/world/handle_loot_fake.rs index 359061f8a..dc3d86fa3 100644 --- a/gateway/src/world/handle_loot_fake.rs +++ b/gateway/src/world/handle_loot_fake.rs @@ -7,7 +7,7 @@ use crate::world::handlers::{ DeathStore, LootActionStatus, LootWindowRequestStatus, LootWindowStore, NpcStore, }; use crate::world::loot::{LootRollStore, PendingLootRoll}; -use crate::world::{ShardRoutingStore, WorldStore}; +use crate::world::{InteractionOutcome, ShardRoutingStore, WorldStore}; use anyhow::{anyhow, Result}; use std::collections::{HashMap, HashSet}; use std::sync::{Arc, Mutex}; @@ -238,7 +238,12 @@ impl NpcStore for HandleLootFake { unreachable!("no test reaches the NPC family") } - fn bind_home(&self, _account_id: u64, _self_guid: u64) -> Result<()> { + fn bind_home( + &self, + _account_id: u64, + _self_guid: u64, + _innkeeper_guid: u64, + ) -> Result { unreachable!("no test reaches the NPC family") } diff --git a/gateway/src/world/handlers/cast/cancel.rs b/gateway/src/world/handlers/cast/cancel.rs index b7730b95f..7da9a8d0d 100644 --- a/gateway/src/world/handlers/cast/cancel.rs +++ b/gateway/src/world/handlers/cast/cancel.rs @@ -1,10 +1,4 @@ -//! The two cancellation routes: `CMSG_CANCEL_CAST` (Esc, moved, recast) and `CMSG_CANCEL_AURA` -//! (the player right-clicked a buff icon). -//! -//! Both are best-effort and silent. The client has already dropped the cast bar or the buff icon -//! before it sends either one, so the gateway answers nothing; the pending-cast and aura relays are -//! the only senders. Both race a durable state change — a cast that just completed, an aura that -//! just expired — so a refusal is normal traffic and must never end the session. +//! Cast, channel and aura cancellation. Durable changes drive the protocol feedback. use super::*; diff --git a/gateway/src/world/handlers/cast/mod.rs b/gateway/src/world/handlers/cast/mod.rs index 64ffd9ff4..e9f574b9e 100644 --- a/gateway/src/world/handlers/cast/mod.rs +++ b/gateway/src/world/handlers/cast/mod.rs @@ -365,7 +365,8 @@ pub(crate) fn dispatch_cast( } }, ClientOpcodeMessage::CMSG_CANCEL_AUTO_REPEAT_SPELL => Ok(ranged::cancel(store, player)), - ClientOpcodeMessage::CMSG_CANCEL_CAST(_) => cancel::cancel_cast(store, player), + ClientOpcodeMessage::CMSG_CANCEL_CAST(_) + | ClientOpcodeMessage::CMSG_CANCEL_CHANNELLING(_) => cancel::cancel_cast(store, player), ClientOpcodeMessage::CMSG_CANCEL_AURA(c) => cancel::cancel_aura(store, player, c.id), other => Ok(CastOutcome::PassThrough(other)), } diff --git a/gateway/src/world/handlers/char.rs b/gateway/src/world/handlers/char.rs index 2a1737e91..adcf1a5e2 100644 --- a/gateway/src/world/handlers/char.rs +++ b/gateway/src/world/handlers/char.rs @@ -1,5 +1,4 @@ -//! Char / world-entry family: character enum + creation (character-select), then enter-world -//! (login and cross-map world-port). Pure code-motion out of `world/mod.rs`. +//! Character selection, world entry, and connection-level ping and Realm Clock replies. use super::super::*; use super::quest::{quest_log_update, QuestActionStore}; @@ -200,6 +199,15 @@ fn enter_world( for (opcode, body) in items.iter().filter_map(codec::build_random_property_values) { send(tx, Outbound::Raw { opcode, body })?; } + for item in &items { + if let Some((bag_slot, position)) = codec::bag_content_parts(item.slot) { + if let Some(bag) = items.iter().find(|bag| bag.slot == bag_slot) { + let (opcode, body) = + codec::build_container_slot_values(bag.guid, position, item.guid); + send(tx, Outbound::Raw { opcode, body })?; + } + } + } // Subscribe AFTER the self-spawn batch is on the wire — so the AOI initial-apply creates for // entities ALREADY in view (notably a questgiver you spawn right next to) arrive AFTER the // client is in-world. Spawning ON a questgiver otherwise left it targetable but with no '!' / @@ -334,6 +342,29 @@ pub(crate) fn handle_char< msg: ClientOpcodeMessage, ) -> Result> { match msg { + ClientOpcodeMessage::CMSG_PING(ping) => { + send( + tx, + Outbound::One(ServerOpcodeMessage::SMSG_PONG( + wow_world_messages::vanilla::SMSG_PONG { + sequence_id: ping.sequence_id, + }, + )), + )?; + } + ClientOpcodeMessage::CMSG_QUERY_TIME => { + let seconds = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs(); + send( + tx, + Outbound::One(ServerOpcodeMessage::SMSG_QUERY_TIME_RESPONSE( + wow_world_messages::vanilla::SMSG_QUERY_TIME_RESPONSE { + time: u32::try_from(seconds)?, + }, + )), + )?; + } // Character-select screen. ClientOpcodeMessage::CMSG_CHAR_ENUM => { let characters = store.characters(conn.account_id)?; @@ -505,7 +536,13 @@ pub(crate) fn handle_char< // 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 => { + ClientOpcodeMessage::CMSG_LOGOUT_CANCEL => { + send( + tx, + Outbound::One(ServerOpcodeMessage::SMSG_LOGOUT_CANCEL_ACK), + )?; + } + ClientOpcodeMessage::CMSG_LOGOUT_REQUEST | ClientOpcodeMessage::CMSG_PLAYER_LOGOUT => { // In-combat gate: deny logout while combat_until_ms is still in the future. We read the // wall-clock here (the gateway is a normal Rust process) and compare against the entity // row's ms-epoch timestamp written by `enter_combat`. 0 = never in combat → allowed. diff --git a/gateway/src/world/handlers/item.rs b/gateway/src/world/handlers/item.rs index 2959c30c3..e358978a8 100644 --- a/gateway/src/world/handlers/item.rs +++ b/gateway/src/world/handlers/item.rs @@ -22,6 +22,13 @@ impl From for ItemActionResult { } pub(crate) trait ItemActionStore: Send + Sync { + fn destroy_item( + &self, + account_id: u64, + actor_guid: u64, + slot: u8, + count: u32, + ) -> Result; fn equip_item( &self, account_id: u64, @@ -42,9 +49,38 @@ pub(crate) trait ItemActionStore: Send + Sync { to_slot: u8, ) -> Result; fn use_item(&self, account_id: u64, actor_guid: u64, slot: u8) -> Result; + fn split_item( + &self, + account_id: u64, + actor_guid: u64, + from_slot: u8, + to_slot: u8, + count: u32, + ) -> Result; } impl ItemActionStore for crate::stdb::Coordinator { + fn destroy_item( + &self, + account_id: u64, + actor_guid: u64, + slot: u8, + count: u32, + ) -> Result { + crate::stdb::Coordinator::destroy_item(self, account_id, actor_guid, slot, count) + } + fn split_item( + &self, + account_id: u64, + actor_guid: u64, + from_slot: u8, + to_slot: u8, + count: u32, + ) -> Result { + crate::stdb::Coordinator::split_item( + self, account_id, actor_guid, from_slot, to_slot, count, + ) + } fn equip_item( &self, account_id: u64, @@ -119,26 +155,48 @@ pub(crate) fn dispatch_item_action Result { match msg { - ClientOpcodeMessage::CMSG_AUTOEQUIP_ITEM(c) if c.source_bag == MAIN_BAG => { - let outbound = inventory_action_outbound( - player.account_id, - "equip_item", - store.equip_item( + ClientOpcodeMessage::CMSG_DESTROYITEM(c) => { + let result = if let Some(slot) = codec::inventory_slot(c.bag, c.slot) { + store.destroy_item( player.account_id, player.self_guid.unwrap_or(0), - c.source_slot, - )?, - ); - Ok(ItemActionOutcome::Handled { outbound }) + slot, + u32::from(c.amount), + )? + } else { + ItemRefusal::WrongSlot.into() + }; + Ok(ItemActionOutcome::Handled { + outbound: inventory_action_outbound(player.account_id, "destroy_item", result), + }) + } + ClientOpcodeMessage::CMSG_SPLIT_ITEM(c) => { + let result = if let (Some(source), Some(destination)) = ( + codec::inventory_slot(c.source_bag, c.source_slot), + codec::inventory_slot(c.destination_bag, c.destination_slot), + ) { + store.split_item( + player.account_id, + player.self_guid.unwrap_or(0), + source, + destination, + u32::from(c.amount), + )? + } else { + ItemRefusal::WrongSlot.into() + }; + Ok(ItemActionOutcome::Handled { + outbound: inventory_action_outbound(player.account_id, "split_item", result), + }) } ClientOpcodeMessage::CMSG_AUTOEQUIP_ITEM(c) => { - log::debug!( - "world: autoequip from sub-bag {} unsupported (account {})", - c.source_bag, - player.account_id - ); + let result = if let Some(slot) = codec::inventory_slot(c.source_bag, c.source_slot) { + store.equip_item(player.account_id, player.self_guid.unwrap_or(0), slot)? + } else { + ItemRefusal::WrongSlot.into() + }; Ok(ItemActionOutcome::Handled { - outbound: Vec::new(), + outbound: inventory_action_outbound(player.account_id, "equip_item", result), }) } ClientOpcodeMessage::CMSG_AUTOSTORE_BAG_ITEM(c) @@ -157,17 +215,13 @@ pub(crate) fn dispatch_item_action { - log::debug!( - "world: autostore (bag {} slot {}) unsupported (account {})", - c.source_bag, - c.source_slot, - player.account_id - ); - Ok(ItemActionOutcome::Handled { - outbound: Vec::new(), - }) - } + ClientOpcodeMessage::CMSG_AUTOSTORE_BAG_ITEM(_) => Ok(ItemActionOutcome::Handled { + outbound: inventory_action_outbound( + player.account_id, + "autostore", + ItemRefusal::NotRightNow.into(), + ), + }), ClientOpcodeMessage::CMSG_SWAP_INV_ITEM(c) => { let outbound = inventory_action_outbound( player.account_id, @@ -181,57 +235,50 @@ pub(crate) fn dispatch_item_action - { - let outbound = inventory_action_outbound( - player.account_id, - "move_item (swap)", + ClientOpcodeMessage::CMSG_SWAP_ITEM(c) => { + let result = if let (Some(source), Some(destination)) = ( + codec::inventory_slot(c.source_bag, c.source_slot), + codec::inventory_slot(c.destination_bag, c.destionation_slot), + ) { store.move_item( player.account_id, player.self_guid.unwrap_or(0), - c.source_slot, - c.destionation_slot, - )?, - ); - Ok(ItemActionOutcome::Handled { outbound }) - } - ClientOpcodeMessage::CMSG_SWAP_ITEM(_) => { - log::debug!( - "world: cross-bag swap unsupported (account {})", - player.account_id - ); + source, + destination, + )? + } else { + ItemRefusal::WrongSlot.into() + }; Ok(ItemActionOutcome::Handled { - outbound: Vec::new(), + outbound: inventory_action_outbound(player.account_id, "move_item", result), }) } // Using an item that starts a quest opens that quest instead of consuming the item, so the // quest family gets first refusal on the slot. - ClientOpcodeMessage::CMSG_USE_ITEM(c) if c.bag_index == MAIN_BAG => { + ClientOpcodeMessage::CMSG_USE_ITEM(c) => { + let Some(slot) = codec::inventory_slot(c.bag_index, c.bag_slot) else { + return Ok(ItemActionOutcome::Handled { + outbound: inventory_action_outbound( + player.account_id, + "use_item", + ItemRefusal::WrongSlot.into(), + ), + }); + }; let quest_player = QuestActionPlayer { account_id: player.account_id, self_guid: player.self_guid, }; - let outbound = match item_started_quest(store, quest_player, c.bag_slot)? { + let outbound = match item_started_quest(store, quest_player, slot)? { Some(details) => details, None => inventory_action_outbound( player.account_id, "use_item", - store.use_item(player.account_id, player.self_guid.unwrap_or(0), c.bag_slot)?, + store.use_item(player.account_id, player.self_guid.unwrap_or(0), slot)?, ), }; Ok(ItemActionOutcome::Handled { outbound }) } - ClientOpcodeMessage::CMSG_USE_ITEM(c) => { - log::debug!( - "world: use_item from sub-bag {} unsupported (account {})", - c.bag_index, - player.account_id - ); - Ok(ItemActionOutcome::Handled { - outbound: Vec::new(), - }) - } other => Ok(ItemActionOutcome::PassThrough(other)), } } @@ -270,6 +317,25 @@ pub(super) mod tests { } impl ItemActionStore for InMemoryItemActions { + fn destroy_item( + &self, + _account_id: u64, + _actor_guid: u64, + _slot: u8, + _count: u32, + ) -> Result { + Ok(ItemRefusal::ItemNotFound.into()) + } + fn split_item( + &self, + _account_id: u64, + _actor_guid: u64, + _from_slot: u8, + _to_slot: u8, + _count: u32, + ) -> Result { + Ok(ItemRefusal::ItemNotFound.into()) + } fn equip_item( &self, account_id: u64, @@ -499,10 +565,10 @@ pub(super) mod tests { } #[test] - fn equip_from_a_sub_bag_requests_no_durable_operation() { + fn equip_from_an_invalid_bag_position_returns_a_refusal() { let actions = InMemoryItemActions::default(); - handled_without_outbound(dispatch_item_action(&actions, player(), equip(19)).unwrap()); + inventory_failure(dispatch_item_action(&actions, player(), equip(19)).unwrap()); assert_no_durable_requests(&actions); } @@ -536,9 +602,7 @@ pub(super) mod tests { fn unequip_from_a_backpack_slot_requests_no_durable_operation() { let actions = InMemoryItemActions::default(); - handled_without_outbound( - dispatch_item_action(&actions, player(), unequip(24, MAIN_BAG)).unwrap(), - ); + inventory_failure(dispatch_item_action(&actions, player(), unequip(24, MAIN_BAG)).unwrap()); assert_no_durable_requests(&actions); } @@ -547,9 +611,7 @@ pub(super) mod tests { fn unequip_into_a_sub_bag_requests_no_durable_operation() { let actions = InMemoryItemActions::default(); - handled_without_outbound( - dispatch_item_action(&actions, player(), unequip(15, 19)).unwrap(), - ); + inventory_failure(dispatch_item_action(&actions, player(), unequip(15, 19)).unwrap()); assert_no_durable_requests(&actions); } @@ -558,7 +620,7 @@ pub(super) mod tests { fn unequip_from_a_sub_bag_requests_no_durable_operation() { let actions = InMemoryItemActions::default(); - handled_without_outbound( + inventory_failure( dispatch_item_action( &actions, player(), @@ -648,10 +710,10 @@ pub(super) mod tests { } #[test] - fn cross_container_swap_requests_no_durable_operation() { + fn invalid_bag_position_returns_a_refusal_without_a_durable_request() { let actions = InMemoryItemActions::default(); - handled_without_outbound(dispatch_item_action(&actions, player(), swap(19)).unwrap()); + inventory_failure(dispatch_item_action(&actions, player(), swap(19)).unwrap()); assert_no_durable_requests(&actions); } @@ -682,12 +744,15 @@ pub(super) mod tests { } #[test] - fn use_from_a_sub_bag_requests_no_durable_operation() { + fn use_from_a_bag_resolves_its_slot_before_the_durable_request() { let actions = InMemoryItemActions::default(); handled_without_outbound(dispatch_item_action(&actions, player(), use_item(19)).unwrap()); - assert_no_durable_requests(&actions); + assert_eq!( + actions.use_requests.lock().unwrap().as_slice(), + &[(7, 42, 125)] + ); } // ── Item-starts-quest ──────────────────────────────────────────────────── @@ -802,6 +867,16 @@ pub(super) mod tests { let (item1, item2, bag_type_subclass) = (Guid::new(0), Guid::new(0), 0u8); // The vanilla 1.12 result each Refusal must reach the client as. let expected = |refusal| match refusal { + ItemRefusal::Indestructible => Failure::CantDropSoulbound { + item1, + item2, + bag_type_subclass, + }, + ItemRefusal::BagNotEmpty => Failure::CanOnlyDoWithEmptyBags { + item1, + item2, + bag_type_subclass, + }, ItemRefusal::ItemNotFound => Failure::ItemNotFound { item1, item2, diff --git a/gateway/src/world/handlers/mod.rs b/gateway/src/world/handlers/mod.rs index 2c18cfb6a..29dff9a97 100644 --- a/gateway/src/world/handlers/mod.rs +++ b/gateway/src/world/handlers/mod.rs @@ -31,6 +31,7 @@ mod quest; mod taxi; mod trade; mod trainer; +mod unavailable; mod vendor; mod weather; @@ -106,7 +107,7 @@ pub(crate) use taxi::{ TaxiActionStore, }; pub(crate) use trade::{handle_trade, TradeStore}; -pub(crate) use trainer::{handle_trainer, TrainerBuyOutcome, TrainerStore}; +pub(crate) use trainer::{handle_at_war, handle_trainer, TrainerBuyOutcome, TrainerStore}; #[cfg(test)] pub(crate) use vendor::tests::InMemoryVendorActions; pub(crate) use vendor::{ @@ -124,3 +125,7 @@ fn send_show_bank(tx: &SessionTx, banker_guid: u64) -> Result<()> { ))), ) } + +pub(crate) use unavailable::{ + is_control_receipt, raw_unavailable_outbound, unavailable_outbound, UnavailableNotice, +}; diff --git a/gateway/src/world/handlers/query.rs b/gateway/src/world/handlers/query.rs index 7de7b3c0b..2a355660b 100644 --- a/gateway/src/world/handlers/query.rs +++ b/gateway/src/world/handlers/query.rs @@ -39,12 +39,15 @@ pub(crate) trait NpcStore: Send + Sync { /// `VendorActionStore::vendor_refuses_interaction`). fn npc_refuses_interaction(&self, npc_guid: u64, player_guid: u64) -> Result; - /// Bind the caller's hearthstone home to their current position (innkeeper gossip "Make this inn - /// your home."). No args — the module resolves the caller via `ctx.sender`. - fn bind_home(&self, account_id: u64, self_guid: u64) -> Result<()>; + /// Bind the Character's home after the Module checks the selected innkeeper. + fn bind_home( + &self, + account_id: u64, + self_guid: u64, + innkeeper_guid: u64, + ) -> Result; - /// Does the NPC at `guid` carry the innkeeper flag? Gates the "Make this inn your home." gossip - /// option + the bind select. + /// Does the NPC at `guid` carry the innkeeper flag? Used to display its gossip option. fn npc_is_innkeeper(&self, guid: u64) -> Result; /// Resolve the `title_text_id` to embed in `SMSG_GOSSIP_MESSAGE` for the NPC at `guid`. @@ -189,6 +192,38 @@ pub(crate) fn handle_query< }; match msg { + ClientOpcodeMessage::CMSG_BINDER_ACTIVATE(request) => { + if self_guid == 0 { + return Ok(None); + } + let result = store.bind_home(conn.account_id, self_guid, request.guid.guid()); + if let InteractionOutcome::Refused(reason) = result? { + send( + tx, + Outbound::One(ServerOpcodeMessage::SMSG_MESSAGECHAT(Box::new( + codec::build_gm_system_message(reason), + ))), + )?; + } + send(tx, Outbound::One(ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE))?; + } + ClientOpcodeMessage::MSG_TALENT_WIPE_CONFIRM(request) => { + if self_guid == 0 { + return Ok(None); + } + if let InteractionOutcome::Refused(reason) = + store.reset_talents(conn.account_id, self_guid, request.wiping_npc.guid())? + { + send( + tx, + Outbound::One(ServerOpcodeMessage::SMSG_MESSAGECHAT(Box::new( + codec::build_gm_system_message(reason), + ))), + )?; + } + send(tx, Outbound::One(ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE))?; + } + // Name resolution: the client asks for a guid's name to render its plate (else "Unknown"). // // Resolved across every connected shard, not just this one. A guid the @@ -358,11 +393,14 @@ pub(crate) fn handle_query< } } Some(gossip_option::INNKEEPER) => { - // Bind failure (not in world) is per-action; close the window either way (the - // post-bind SMSG_BINDPOINTUPDATE confirmation is cosmetic — sent fresh at next - // login; the recall is server-authoritative regardless). - let _ = store.bind_home(conn.account_id, social::self_guid(conn).unwrap_or(0)); send(tx, Outbound::One(ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE))?; + send( + tx, + Outbound::Raw { + opcode: 0x02eb, + body: npc.to_le_bytes().to_vec(), + }, + )?; } Some(gossip_option::TRAINER) => { let spells = store.trainer_list(player_guid, npc)?; @@ -374,10 +412,27 @@ pub(crate) fn handle_query< )?; } Some(gossip_option::UNLEARNTALENTS) => { - // 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))?; + if let Some(cost) = store.talent_reset_cost(player_guid) { + let mut body = npc.to_le_bytes().to_vec(); + body.extend_from_slice(&cost.to_le_bytes()); + send( + tx, + Outbound::Raw { + opcode: 0x02aa, + body, + }, + )?; + } else { + send( + tx, + Outbound::One(ServerOpcodeMessage::SMSG_MESSAGECHAT(Box::new( + codec::build_gm_system_message( + "The talent reset quote is not available. Try again.".into(), + ), + ))), + )?; + } } Some(gossip_option::TAXI) => { for message in open_taxi_outbound(store, player_guid, npc)? { diff --git a/gateway/src/world/handlers/quest.rs b/gateway/src/world/handlers/quest.rs index 8b262ff83..cfb5c07b2 100644 --- a/gateway/src/world/handlers/quest.rs +++ b/gateway/src/world/handlers/quest.rs @@ -306,6 +306,40 @@ pub(crate) fn quest_gate_state( store.quest_status(player_guid, quest_id) } +/// Both opening a turn-in and continuing its progress screen use the current durable evaluation. +fn quest_reward_screen( + store: &St, + giver: u64, + self_guid: u64, + quest_id: u32, +) -> Result { + let Some(detail) = store.quest_detail_view(quest_id)? else { + return Ok(QuestActionOutcome::Handled { + outbound: Vec::new(), + }); + }; + let complete = store + .giver_quest_evals(giver, self_guid)? + .iter() + .any(|evaluation| { + evaluation.quest_id == quest_id + && evaluation.role == codec::ROLE_END + && evaluation.complete + }); + let screen = if complete { + ServerOpcodeMessage::SMSG_QUESTGIVER_OFFER_REWARD(Box::new(codec::build_offer_reward( + giver, &detail, + ))) + } else { + ServerOpcodeMessage::SMSG_QUESTGIVER_REQUEST_ITEMS(Box::new(codec::build_request_items( + giver, &detail, false, + ))) + }; + Ok(QuestActionOutcome::Handled { + outbound: vec![Outbound::One(screen)], + }) +} + /// The quest opcodes that own their whole protocol round trip. Anything else — and anything at all /// before world entry — passes through to the next family in the dispatch chain. pub(crate) fn dispatch_quest_action( @@ -394,32 +428,11 @@ pub(crate) fn dispatch_quest_action( outbound: Vec::new(), }) } - // Opened a turn-in (clicked the `?`): the offer-reward screen when the giver's current - // evaluation reports the quest complete, else the request-items "not finished" screen. The - // module is the authority on completion; this only picks the screen and grants nothing. ClientOpcodeMessage::CMSG_QUESTGIVER_COMPLETE_QUEST(c) => { - let giver = c.guid.guid(); - let Some(detail) = store.quest_detail_view(c.quest_id)? else { - return Ok(QuestActionOutcome::Handled { - outbound: Vec::new(), - }); - }; - let complete = store - .giver_quest_evals(giver, self_guid)? - .iter() - .any(|e| e.quest_id == c.quest_id && e.role == codec::ROLE_END && e.complete); - let screen = if complete { - ServerOpcodeMessage::SMSG_QUESTGIVER_OFFER_REWARD(Box::new( - codec::build_offer_reward(giver, &detail), - )) - } else { - ServerOpcodeMessage::SMSG_QUESTGIVER_REQUEST_ITEMS(Box::new( - codec::build_request_items(giver, &detail, false), - )) - }; - Ok(QuestActionOutcome::Handled { - outbound: vec![Outbound::One(screen)], - }) + quest_reward_screen(store, c.guid.guid(), self_guid, c.quest_id) + } + ClientOpcodeMessage::CMSG_QUESTGIVER_REQUEST_REWARD(c) => { + quest_reward_screen(store, c.guid.guid(), self_guid, c.quest_id) } // Chose the reward → the module grants money/XP/items (gated on completion). The durable // turn-in is requested BEFORE any outbound is built, so a refused turn-in can never show a diff --git a/gateway/src/world/handlers/trainer.rs b/gateway/src/world/handlers/trainer.rs index be61585a3..57d63e9df 100644 --- a/gateway/src/world/handlers/trainer.rs +++ b/gateway/src/world/handlers/trainer.rs @@ -59,7 +59,7 @@ pub(crate) trait TrainerStore: Send + Sync { self_guid: u64, reputation_index: u32, at_war: bool, - ) -> Result<()>; + ) -> Result; /// Talent-pane sync after a successful `learn_talent`: `(teach_spell, superseded_prev, /// points_remaining)` — the rank-spell to relay as LEARNED/SUPERCEDED (the 1.12 TalentFrame @@ -74,10 +74,16 @@ pub(crate) trait TrainerStore: Send + Sync { /// / max rank / prerequisites); a gameplay `Err` is per-action, not session-fatal. fn learn_talent(&self, account_id: u64, self_guid: u64, talent_id: u32) -> Result<()>; - /// Respec at `trainer_guid` (the "I wish to unlearn my talents." gossip option, gated to level - /// 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<()>; + /// Quote the Module's current talent reset cost for the confirmation dialog. + fn talent_reset_cost(&self, character_guid: u64) -> Option; + + /// Confirm a talent reset. The Module checks the trainer, range and price. + fn reset_talents( + &self, + account_id: u64, + self_guid: u64, + trainer_guid: u64, + ) -> Result; /// The rank a trainer offering actually teaches (LearnSpell wrapper → its trigger; a /// self-contained rank resolves to itself). Mirrors the module's buy-time resolution so @@ -258,22 +264,6 @@ pub(crate) fn handle_trainer { - let reputation_index = c.faction.as_int() as u32; - let at_war = c.flags.is_at_war(); - if let Err(e) = - store.set_faction_at_war(conn.account_id, self_guid, reputation_index, at_war) - { - log::debug!( - "world: set_faction_at_war ignored (account {}): {e}", - conn.account_id - ); - } - } ClientOpcodeMessage::CMSG_LEARN_TALENT(c) => { let talent_id = c.talent.as_int(); let grant_spell_id = store.talent_grant_spell(talent_id); @@ -375,3 +365,34 @@ fn send_armor_proficiency( Outbound::One(codec::build_armor_proficiency_msg(player_class, &learned)), ) } + +/// The vanilla request names a reputation-list index, not a Faction.dbc id. +pub(crate) fn handle_at_war( + tx: &SessionTx, + store: &St, + conn: &mut WorldConn, + body: &[u8], +) -> Result<()> { + anyhow::ensure!( + body.len() == 5 && body[4] <= 1, + "invalid CMSG_SET_FACTION_ATWAR body" + ); + let Some(character_guid) = social::self_guid(conn) else { + return Ok(()); + }; + let index = u32::from_le_bytes(body[..4].try_into()?); + if let Some((opcode, info)) = conn.move_coalesce.flush_now() { + forward_movement(store, conn, opcode, &info)?; + } + if let InteractionOutcome::Refused(reason) = + store.set_faction_at_war(conn.account_id, character_guid, index, body[4] != 0)? + { + send( + tx, + Outbound::One(ServerOpcodeMessage::SMSG_MESSAGECHAT(Box::new( + codec::build_gm_system_message(reason), + ))), + )?; + } + Ok(()) +} diff --git a/gateway/src/world/handlers/unavailable.rs b/gateway/src/world/handlers/unavailable.rs new file mode 100644 index 000000000..c243b5b41 --- /dev/null +++ b/gateway/src/world/handlers/unavailable.rs @@ -0,0 +1,295 @@ +//! Protocol responses for services whose durable gameplay is not implemented. + +use super::super::*; + +fn word(opcode: u16, value: u32) -> Outbound { + Outbound::Raw { + opcode, + body: value.to_le_bytes().to_vec(), + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) enum UnavailableNotice { + StandState, + ActionBar, + Tutorial, +} + +pub(crate) fn unavailable_outbound( + msg: &ClientOpcodeMessage, + notices: &mut std::collections::HashSet, +) -> Option> { + let notice = match msg { + ClientOpcodeMessage::CMSG_STANDSTATECHANGE(_) => { + Some((UnavailableNotice::StandState, "Stand-state changes")) + } + ClientOpcodeMessage::CMSG_SET_ACTIONBAR_TOGGLES(_) => Some(( + UnavailableNotice::ActionBar, + "Action bar visibility preferences", + )), + ClientOpcodeMessage::CMSG_TUTORIAL_FLAG(_) + | ClientOpcodeMessage::CMSG_TUTORIAL_CLEAR + | ClientOpcodeMessage::CMSG_TUTORIAL_RESET => { + Some((UnavailableNotice::Tutorial, "Tutorial preferences")) + } + _ => None, + }; + if let Some((notice, service)) = notice { + return Some(if notices.insert(notice) { + vec![unavailable(service)] + } else { + vec![] + }); + } + let response = match msg { + ClientOpcodeMessage::CMSG_GMTICKET_SYSTEMSTATUS => word(0x021b, 0), + ClientOpcodeMessage::CMSG_GMTICKET_GETTICKET => word(0x0212, 10), + // Deletion is idempotent when no ticket exists. Creation never claims success. + ClientOpcodeMessage::CMSG_GMTICKET_DELETETICKET => word(0x0218, 9), + ClientOpcodeMessage::MSG_LIST_STABLED_PETS(_) + | ClientOpcodeMessage::CMSG_STABLE_PET(_) + | ClientOpcodeMessage::CMSG_UNSTABLE_PET(_) + | ClientOpcodeMessage::CMSG_BUY_STABLE_SLOT(_) + | ClientOpcodeMessage::CMSG_STABLE_SWAP_PET(_) => Outbound::Raw { + opcode: 0x0273, + body: vec![6], + }, + ClientOpcodeMessage::CMSG_OPEN_ITEM(_) | ClientOpcodeMessage::CMSG_WRAP_ITEM(_) => { + Outbound::One(ServerOpcodeMessage::SMSG_INVENTORY_CHANGE_FAILURE( + Box::new(codec::build_inventory_refusal( + lyracore_shared::item::ItemRefusal::NotRightNow, + )), + )) + } + ClientOpcodeMessage::CMSG_BATTLEFIELD_LIST(request) => { + let mut body = 0u64.to_le_bytes().to_vec(); + body.extend_from_slice(&request.map.as_int().to_le_bytes()); + body.extend_from_slice(&[0; 5]); + Outbound::Raw { + opcode: 0x023d, + body, + } + } + ClientOpcodeMessage::CMSG_BATTLEFIELD_STATUS + | ClientOpcodeMessage::CMSG_LEAVE_BATTLEFIELD(_) => return Some(empty_battlefield_status()), + ClientOpcodeMessage::CMSG_BATTLEMASTER_JOIN(_) + | ClientOpcodeMessage::CMSG_BATTLEFIELD_PORT(_) => return Some(battlefield_refusal()), + ClientOpcodeMessage::CMSG_BATTLEMASTER_HELLO(_) + | ClientOpcodeMessage::CMSG_AREA_SPIRIT_HEALER_QUERY(_) + | ClientOpcodeMessage::CMSG_AREA_SPIRIT_HEALER_QUEUE(_) => unavailable("Battlegrounds"), + ClientOpcodeMessage::MSG_PVP_LOG_DATA => Outbound::Raw { + opcode: 0x02e0, + body: vec![0; 5], + }, + ClientOpcodeMessage::MSG_BATTLEGROUND_PLAYER_POSITIONS => Outbound::Raw { + opcode: 0x02e9, + body: vec![0; 5], + }, + ClientOpcodeMessage::CMSG_PET_SET_ACTION(_) => unavailable("Pet action bar changes"), + ClientOpcodeMessage::CMSG_PET_ABANDON(_) => unavailable("Pet abandonment requests"), + ClientOpcodeMessage::CMSG_PET_RENAME(_) => unavailable("Pet name changes"), + ClientOpcodeMessage::CMSG_PET_STOP_ATTACK(_) => unavailable("Pet stop-attack requests"), + ClientOpcodeMessage::CMSG_PET_CANCEL_AURA(_) => unavailable("Pet aura cancellations"), + ClientOpcodeMessage::CMSG_PET_UNLEARN(_) => unavailable("Pet talent resets"), + ClientOpcodeMessage::CMSG_PET_SPELL_AUTOCAST(_) => unavailable("Pet autocast changes"), + ClientOpcodeMessage::CMSG_UNLEARN_SKILL(_) => unavailable("Skill unlearning requests"), + ClientOpcodeMessage::CMSG_SET_AMMO(_) => unavailable("Ammunition selections"), + ClientOpcodeMessage::CMSG_MOUNTSPECIAL_ANIM => unavailable("Mount flourishes"), + ClientOpcodeMessage::CMSG_TOGGLE_PVP(_) => unavailable("PvP flag changes"), + ClientOpcodeMessage::CMSG_TOGGLE_HELM | ClientOpcodeMessage::CMSG_TOGGLE_CLOAK => { + unavailable("Equipment visibility preferences") + } + ClientOpcodeMessage::CMSG_FAR_SIGHT(_) => unavailable("Remote camera requests"), + ClientOpcodeMessage::CMSG_SUMMON_RESPONSE(_) => unavailable("Summon confirmations"), + ClientOpcodeMessage::CMSG_QUEST_CONFIRM_ACCEPT(_) => { + unavailable("Quest sharing confirmations") + } + ClientOpcodeMessage::MSG_QUEST_PUSH_RESULT(_) => unavailable("Quest sharing responses"), + ClientOpcodeMessage::CMSG_RESET_INSTANCES => unavailable("Instance reset requests"), + ClientOpcodeMessage::CMSG_REQUEST_RAID_INFO => unavailable("Raid lockout queries"), + ClientOpcodeMessage::MSG_INSPECT_HONOR_STATS(_) => unavailable("Honor statistics"), + ClientOpcodeMessage::CMSG_BUY_ITEM_IN_SLOT(_) => Outbound::One( + ServerOpcodeMessage::SMSG_INVENTORY_CHANGE_FAILURE(Box::new( + codec::build_inventory_refusal(lyracore_shared::item::ItemRefusal::NotRightNow), + )), + ), + _ => return None, + }; + Some(vec![response]) +} + +fn unavailable(service: &str) -> Outbound { + Outbound::One(ServerOpcodeMessage::SMSG_MESSAGECHAT(Box::new( + codec::build_gm_system_message(format!("{service} are not available on this realm.")), + ))) +} + +fn empty_battlefield_status() -> Vec { + (0u32..3) + .map(|slot| { + let mut body = slot.to_le_bytes().to_vec(); + body.extend_from_slice(&0u32.to_le_bytes()); + Outbound::Raw { + opcode: 0x02d4, + body, + } + }) + .collect() +} + +fn battlefield_refusal() -> Vec { + let mut replies = vec![unavailable("Battlegrounds")]; + replies.extend(empty_battlefield_status()); + replies +} + +/// Build 5875 layouts that differ from the protocol library's definitions. +pub(crate) fn raw_unavailable_outbound(opcode: u32, body: &[u8]) -> Result>> { + match opcode { + 0x0205 => { + anyhow::ensure!(body.len() >= 19, "short CMSG_GMTICKET_CREATE body"); + let tail = after_cstring(after_cstring(&body[17..])?)?; + // Benilla omits the optional harassment transcript. Its size headers are + // untrusted; an unavailable service has no reason to decompress it. + anyhow::ensure!( + tail.is_empty() || (body[0] == 2 && tail.len() >= 8), + "invalid CMSG_GMTICKET_CREATE transcript" + ); + Ok(Some(vec![word(0x0206, 3)])) + } + 0x0207 => { + anyhow::ensure!(body.len() >= 2, "short CMSG_GMTICKET_UPDATETEXT body"); + anyhow::ensure!( + after_cstring(&body[1..])?.is_empty(), + "invalid CMSG_GMTICKET_UPDATETEXT body" + ); + Ok(Some(vec![word(0x0208, 5)])) + } + 0x023e => { + anyhow::ensure!( + body.len() == 9 && body[8] <= 1, + "invalid CMSG_BATTLEFIELD_JOIN body" + ); + Ok(Some(battlefield_refusal())) + } + 0x00f9 => { + anyhow::ensure!(body.is_empty(), "invalid CMSG_OPENING_CINEMATIC body"); + Ok(Some(vec![unavailable("Opening cinematics")])) + } + 0x0200 => { + anyhow::ensure!( + body.len() >= 13 + && body.last() == Some(&0) + && !body[12..body.len() - 1].contains(&0), + "invalid CMSG_SET_LOOKING_FOR_GROUP body" + ); + std::str::from_utf8(&body[12..body.len() - 1])?; + Ok(Some(vec![unavailable("Looking-for-group preferences")])) + } + 0x0312 => { + anyhow::ensure!(body.len() >= 16, "short CMSG_ACTIVATETAXIEXPRESS body"); + let count = u32::from_le_bytes(body[12..16].try_into()?); + anyhow::ensure!( + u64::from(count) * 4 + 16 == body.len() as u64, + "invalid CMSG_ACTIVATETAXIEXPRESS node count" + ); + Ok(Some(vec![word(0x01ae, 1)])) + } + 0x0317 => { + anyhow::ensure!( + body.len() == 5 && body[4] <= 1, + "invalid CMSG_SET_FACTION_INACTIVE body" + ); + Ok(Some(vec![unavailable("Reputation display preferences")])) + } + 0x0318 => { + anyhow::ensure!(body.len() == 4, "invalid CMSG_SET_WATCHED_FACTION body"); + Ok(Some(vec![unavailable("Reputation display preferences")])) + } + 0x005a => { + anyhow::ensure!(body.len() == 12, "invalid CMSG_PAGE_TEXT_QUERY body"); + Ok(Some(vec![page_unavailable(u32::from_le_bytes( + body[..4].try_into()?, + ))])) + } + _ => Ok(None), + } +} + +fn after_cstring(body: &[u8]) -> Result<&[u8]> { + let end = body + .iter() + .position(|byte| *byte == 0) + .ok_or_else(|| anyhow!("unterminated ticket text"))?; + std::str::from_utf8(&body[..end])?; + Ok(&body[end + 1..]) +} + +fn page_unavailable(page_id: u32) -> Outbound { + let mut body = page_id.to_le_bytes().to_vec(); + body.extend_from_slice(b"Page text is not available on this realm.\0"); + body.extend_from_slice(&0u32.to_le_bytes()); + Outbound::Raw { + opcode: 0x005b, + body, + } +} + +/// These receipts do not change the authoritative pose. Movement counters and cinematic +/// cameras have no durable state yet; ordinary movement still crosses the Store Seam. +pub(crate) fn is_control_receipt(msg: &ClientOpcodeMessage) -> bool { + matches!( + msg, + ClientOpcodeMessage::MSG_MOVE_TELEPORT_ACK(_) + | ClientOpcodeMessage::CMSG_FORCE_RUN_BACK_SPEED_CHANGE_ACK(_) + | ClientOpcodeMessage::CMSG_FORCE_SWIM_SPEED_CHANGE_ACK(_) + | ClientOpcodeMessage::CMSG_FORCE_MOVE_ROOT_ACK(_) + | ClientOpcodeMessage::CMSG_FORCE_MOVE_UNROOT_ACK(_) + | ClientOpcodeMessage::CMSG_MOVE_KNOCK_BACK_ACK(_) + | ClientOpcodeMessage::CMSG_MOVE_HOVER_ACK(_) + | ClientOpcodeMessage::CMSG_MOVE_FEATHER_FALL_ACK(_) + | ClientOpcodeMessage::CMSG_MOVE_WATER_WALK_ACK(_) + | ClientOpcodeMessage::CMSG_FORCE_WALK_SPEED_CHANGE_ACK(_) + | ClientOpcodeMessage::CMSG_FORCE_SWIM_BACK_SPEED_CHANGE_ACK(_) + | ClientOpcodeMessage::CMSG_FORCE_TURN_RATE_CHANGE_ACK(_) + | ClientOpcodeMessage::CMSG_MOVE_SPLINE_DONE(_) + | ClientOpcodeMessage::CMSG_MOVE_TIME_SKIPPED(_) + | ClientOpcodeMessage::CMSG_MOVE_NOT_ACTIVE_MOVER(_) + | ClientOpcodeMessage::CMSG_NEXT_CINEMATIC_CAMERA + | ClientOpcodeMessage::CMSG_COMPLETE_CINEMATIC + ) +} + +#[cfg(test)] +mod tests { + use super::raw_unavailable_outbound; + + #[test] + fn ticket_refusals_require_complete_packet_framing() { + for body in [ + vec![], + vec![0; 17], + { + let mut body = vec![0; 17]; + body.extend_from_slice(b"text\0unterminated"); + body + }, + { + let mut body = vec![0; 17]; + body.extend_from_slice(b"text\0reserved\0extra"); + body + }, + ] { + assert!(raw_unavailable_outbound(0x0205, &body).is_err()); + } + for body in [ + b"".as_slice(), + b"\x01text", + b"\x01text\0extra", + b"\x01\xff\0", + ] { + assert!(raw_unavailable_outbound(0x0207, body).is_err()); + } + } +} diff --git a/gateway/src/world/mod.rs b/gateway/src/world/mod.rs index f2a323d26..ed27ec687 100644 --- a/gateway/src/world/mod.rs +++ b/gateway/src/world/mod.rs @@ -92,8 +92,8 @@ pub(crate) use party::PartyStore; use social::handle_social; pub(crate) use social::ContactOutcome; pub(crate) use social::SocialStore; +pub use store::{InteractionOutcome, WorldSessionToken, WorldStore}; pub(crate) use store::{SessionStore, ShardRoutingStore}; -pub use store::{WorldSessionToken, WorldStore}; pub(crate) use transfer::TransferStore; /// One unit of outbound traffic for the single writer thread. A `Batch` is written contiguously so @@ -377,6 +377,8 @@ pub struct WorldConn { /// condition-filtered list, so re-deriving that list at click time renumbers it under a quest /// accepted while the window was open. pub(crate) gossip_menu: Option, + /// Automatic client requests announce each missing feature once per World Session. + unavailable_notices: std::collections::HashSet, /// The Store every message of this session runs against. store: RoutedStore, /// The shared session key K, kept from the world handshake so a session that lands on a NON- @@ -694,6 +696,7 @@ fn world_handshake_with_queue_and_deadline( // Auth is over, and the typed decoder would size a buffer from the body. return Err(anyhow!("world read error: CMSG_AUTH_SESSION after auth")); } - let mut framed = Vec::with_capacity(6 + body.len()); - framed.extend_from_slice(&hdr.size.to_be_bytes()); - framed.extend_from_slice(&hdr.opcode.to_le_bytes()); - framed.extend_from_slice(&body); - let msg = match ClientOpcodeMessage::read_unencrypted(&mut std::io::Cursor::new(framed)) - { - Ok(m) => m, - Err(e) => return Err(anyhow!("world read error: {e}")), + if hdr.opcode == 0x0125 { + let store = conn.store.current(); + handlers::handle_at_war(&tx, &*store, &mut conn, &body)?; + continue; + } + if let Some(replies) = handlers::raw_unavailable_outbound(hdr.opcode, &body)? { + if matches!(conn.state, WorldState::InWorld(_)) { + for reply in replies { + send(&tx, reply)?; + } + } + continue; + } + let msg = match codec::read_movement_client(hdr.opcode, &body)? { + Some(msg) => msg, + None => { + let mut framed = Vec::with_capacity(6 + body.len()); + framed.extend_from_slice(&hdr.size.to_be_bytes()); + framed.extend_from_slice(&hdr.opcode.to_le_bytes()); + framed.extend_from_slice(&body); + ClientOpcodeMessage::read_unencrypted(&mut std::io::Cursor::new(framed)) + .map_err(|e| anyhow!("world read error: {e}"))? + } }; // Every in-world message is dispatched against the player's HOME shard; until // CMSG_PLAYER_LOGIN resolves one (and always, with a single-entry shard map) this is @@ -1611,7 +1629,11 @@ fn dispatch( // field-TYPE lie; cmangos reads u32) — reinterpret the raw bits. if let ClientOpcodeMessage::MSG_MOVE_FALL_LAND(mv) = &msg { if let WorldState::InWorld(iw) = &conn.state { - let fall_time_ms = mv.info.fall_time.to_bits(); + let fall_time_ms = if mv.info.flags.get_on_transport().is_some() { + 0 + } else { + mv.info.fall_time.to_bits() + }; let dmg = lyracore_shared::env::fall_damage( fall_time_ms, store.entity_max_health(iw.self_guid), @@ -1641,7 +1663,30 @@ fn dispatch( } } } else { - log::debug!("world: ignoring {msg} (account {})", conn.account_id); + if handlers::is_control_receipt(&msg) { + return Ok(()); + } + if let ClientOpcodeMessage::CMSG_SET_ACTIVE_MOVER(request) = &msg { + if social::self_guid(conn) == Some(request.guid.guid()) { + return Ok(()); + } + return send( + tx, + Outbound::One(ServerOpcodeMessage::SMSG_MESSAGECHAT(Box::new( + codec::build_gm_system_message( + "Control of another mover is not available on this realm.".into(), + ), + ))), + ); + } + if let Some(outbound) = handlers::unavailable_outbound(&msg, &mut conn.unavailable_notices) + { + for message in outbound { + send(tx, message)?; + } + } else { + log::debug!("world: ignoring {msg} (account {})", conn.account_id); + } } Ok(()) } @@ -1951,4 +1996,4 @@ pub async fn run( #[cfg(all(test, unix))] mod test_support; #[cfg(all(test, unix))] -mod tests; +pub(crate) mod tests; diff --git a/gateway/src/world/npc_tests.rs b/gateway/src/world/npc_tests.rs index d4ac87c71..8771df053 100644 --- a/gateway/src/world/npc_tests.rs +++ b/gateway/src/world/npc_tests.rs @@ -259,6 +259,22 @@ fn gossip_select_on_an_innkeeper_binds_home_and_completes() { ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE => {} other => panic!("expected SMSG_GOSSIP_COMPLETE, got {other}"), } + let ServerOpcodeMessage::SMSG_BINDER_CONFIRM(confirm) = + ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() + else { + panic!("expected innkeeper confirmation"); + }; + assert!(!store + .npc + .home_bound + .load(std::sync::atomic::Ordering::SeqCst)); + wow_world_messages::vanilla::CMSG_BINDER_ACTIVATE { guid: confirm.guid } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + assert!(matches!( + ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(), + ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE + )); drop(client); server.join().unwrap(); assert!( @@ -385,6 +401,7 @@ fn gossip_select_on_an_imported_vendor_option_opens_the_inventory_window() { fn gossip_select_on_an_imported_innkeeper_option_binds_home() { use lyracore_shared::constants::gossip_option; let mut s = quest_store(); + s.npc.innkeeper = true; s.npc.gossip_opts = vec![ opt(0, "Chat.", gossip_option::GOSSIP), opt(0, "Stay here.", gossip_option::INNKEEPER), @@ -403,6 +420,22 @@ fn gossip_select_on_an_imported_innkeeper_option_binds_home() { ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE => {} other => panic!("expected SMSG_GOSSIP_COMPLETE, got {other}"), } + let ServerOpcodeMessage::SMSG_BINDER_CONFIRM(confirm) = + ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() + else { + panic!("expected innkeeper confirmation"); + }; + assert!(!store + .npc + .home_bound + .load(std::sync::atomic::Ordering::SeqCst)); + wow_world_messages::vanilla::CMSG_BINDER_ACTIVATE { guid: confirm.guid } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + assert!(matches!( + ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(), + ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE + )); drop(client); server.join().unwrap(); assert!( @@ -441,6 +474,10 @@ fn the_same_option_row_reaches_the_module_by_row_id_from_either_viewer() { .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); let _ = ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); + assert!(matches!( + ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(), + ServerOpcodeMessage::SMSG_BINDER_CONFIRM(_) + )); drop(client); server.join().unwrap(); @@ -460,6 +497,10 @@ fn the_same_option_row_reaches_the_module_by_row_id_from_either_viewer() { .write_encrypted_client(&mut client, &mut c_enc) .unwrap(); let _ = ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(); + assert!(matches!( + ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(), + ServerOpcodeMessage::SMSG_BINDER_CONFIRM(_) + )); drop(client); server.join().unwrap(); @@ -477,6 +518,7 @@ fn the_same_option_row_reaches_the_module_by_row_id_from_either_viewer() { fn a_quest_taken_while_the_window_is_open_does_not_shift_the_click() { use lyracore_shared::constants::{gossip_condition, gossip_option}; let mut s = quest_store(); + s.npc.innkeeper = true; let mut gated = opt(0, "About that favor...", gossip_option::GOSSIP); gated.row_id = 4001; gated.cond_type = gossip_condition::QUEST_TAKEN; @@ -503,6 +545,22 @@ fn a_quest_taken_while_the_window_is_open_does_not_shift_the_click() { ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE => {} other => panic!("expected SMSG_GOSSIP_COMPLETE, got {other}"), } + let ServerOpcodeMessage::SMSG_BINDER_CONFIRM(confirm) = + ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() + else { + panic!("expected innkeeper confirmation"); + }; + assert!(!store + .npc + .home_bound + .load(std::sync::atomic::Ordering::SeqCst)); + wow_world_messages::vanilla::CMSG_BINDER_ACTIVATE { guid: confirm.guid } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + assert!(matches!( + ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(), + ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE + )); drop(client); server.join().unwrap(); assert!( @@ -604,6 +662,22 @@ fn an_imported_menu_missing_its_bind_row_still_offers_the_hearth() { ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE => {} other => panic!("expected SMSG_GOSSIP_COMPLETE, got {other}"), } + let ServerOpcodeMessage::SMSG_BINDER_CONFIRM(confirm) = + ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() + else { + panic!("expected innkeeper confirmation"); + }; + assert!(!store + .npc + .home_bound + .load(std::sync::atomic::Ordering::SeqCst)); + wow_world_messages::vanilla::CMSG_BINDER_ACTIVATE { guid: confirm.guid } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + assert!(matches!( + ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(), + ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE + )); drop(client); server.join().unwrap(); assert!(store @@ -697,8 +771,7 @@ fn gossip_hello_shows_unlearn_talents_at_level_10_and_select_routes_to_reset_tal } other => panic!("expected SMSG_GOSSIP_MESSAGE, got {other}"), } - // Click it (index 1, same list HELLO just rendered) — must route to reset_talents, not just - // close the window inert. + // Selecting the option quotes the cost without changing talents. CMSG_GOSSIP_SELECT_OPTION { guid: Guid::new(90), gossip_list_id: 1, @@ -710,6 +783,22 @@ fn gossip_hello_shows_unlearn_talents_at_level_10_and_select_routes_to_reset_tal ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE => {} other => panic!("expected SMSG_GOSSIP_COMPLETE, got {other}"), } + let ServerOpcodeMessage::MSG_TALENT_WIPE_CONFIRM(confirm) = + ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() + else { + panic!("expected talent reset quote"); + }; + assert_eq!(confirm.cost_in_copper, 10_000); + assert!(store.trainer.reset_talents_calls.lock().unwrap().is_empty()); + wow_world_messages::vanilla::MSG_TALENT_WIPE_CONFIRM_Client { + wiping_npc: confirm.wiping_npc, + } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + assert!(matches!( + ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(), + ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE + )); drop(client); server.join().unwrap(); let calls = store.trainer.reset_talents_calls.lock().unwrap(); @@ -757,6 +846,7 @@ fn gossip_hello_and_select_option_stay_position_aligned_under_a_hidden_option() // `filtered_gossip_options` re-derives the IDENTICAL list rather than indexing the raw rows. use lyracore_shared::constants::{gossip_condition, gossip_option}; let mut s = quest_store(); + s.npc.innkeeper = true; s.npc.gossip_opts = vec![ opt(0, "Chat.", gossip_option::GOSSIP), // raw index 0 -> rendered index 0 opt(0, "Hidden favor.", gossip_option::GOSSIP), // raw index 1 -> HIDDEN (quest-gated) @@ -792,6 +882,22 @@ fn gossip_hello_and_select_option_stay_position_aligned_under_a_hidden_option() ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE => {} other => panic!("expected SMSG_GOSSIP_COMPLETE, got {other}"), } + let ServerOpcodeMessage::SMSG_BINDER_CONFIRM(confirm) = + ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap() + else { + panic!("expected innkeeper confirmation"); + }; + assert!(!store + .npc + .home_bound + .load(std::sync::atomic::Ordering::SeqCst)); + wow_world_messages::vanilla::CMSG_BINDER_ACTIVATE { guid: confirm.guid } + .write_encrypted_client(&mut client, &mut c_enc) + .unwrap(); + assert!(matches!( + ServerOpcodeMessage::read_encrypted(&mut client, &mut c_dec).unwrap(), + ServerOpcodeMessage::SMSG_GOSSIP_COMPLETE + )); drop(client); server.join().unwrap(); assert!( diff --git a/gateway/src/world/store.rs b/gateway/src/world/store.rs index b63e17015..4b3ee3f2f 100644 --- a/gateway/src/world/store.rs +++ b/gateway/src/world/store.rs @@ -11,6 +11,13 @@ pub struct WorldSessionToken { pub request_nonce: u128, } +/// A completed Character request. Infrastructure failures remain an outer `Err`. +#[derive(Debug, PartialEq, Eq)] +pub enum InteractionOutcome { + Done, + Refused(String), +} + /// Every Store family a World Session reaches. The blanket impl below supplies it, so a Store /// implements the families and never this trait. pub trait WorldStore: diff --git a/gateway/src/world/test_support/entry.rs b/gateway/src/world/test_support/entry.rs index bb71e1581..3da1f205f 100644 --- a/gateway/src/world/test_support/entry.rs +++ b/gateway/src/world/test_support/entry.rs @@ -121,6 +121,10 @@ pub(crate) fn auth_session( /// fields via `..`. `quest_store()` is the sibling for tests that also need a login entity. pub(crate) fn tester_store(account_id: u64) -> WorldFake { WorldFake { + trainer: TrainerState { + talent_reset_quote: Some(10_000), + ..Default::default() + }, session: SessionState { entity_in_world: true, username: "TESTER".into(), diff --git a/gateway/src/world/test_support/family.rs b/gateway/src/world/test_support/family.rs index 62d39d5e8..c9fbe6f66 100644 --- a/gateway/src/world/test_support/family.rs +++ b/gateway/src/world/test_support/family.rs @@ -31,6 +31,7 @@ pub(crate) fn in_world_conn(account_id: u64, self_guid: u64) -> WorldConn { }), move_coalesce: Default::default(), gossip_menu: None, + unavailable_notices: Default::default(), store: RoutedStore::new(Arc::new(WorldFake::default())), session_key: None, guild_signed_on: None, @@ -97,7 +98,12 @@ macro_rules! npc_store_refusing_by { unimplemented!("enter_areatrigger") } - fn bind_home(&self, _account_id: u64, _self_guid: u64) -> Result<()> { + fn bind_home( + &self, + _account_id: u64, + _self_guid: u64, + _innkeeper_guid: u64, + ) -> Result { unimplemented!("bind_home") } diff --git a/gateway/src/world/test_support/world_fake/cast.rs b/gateway/src/world/test_support/world_fake/cast.rs index c534505ab..c3927b5df 100644 --- a/gateway/src/world/test_support/world_fake/cast.rs +++ b/gateway/src/world/test_support/world_fake/cast.rs @@ -104,6 +104,9 @@ impl CastStore for WorldFake { // Shared with the character, vendor and query paths, so these two keep real fixtures. fn player_items(&self, _owner_guid: u64) -> Result> { + if let Some(state) = &self.benilla_gameplay { + return Ok(state.lock().unwrap().inventory.clone()); + } Ok(self.cast.player_items_fixture.clone()) } fn item_template(&self, entry: u32) -> Result> { diff --git a/gateway/src/world/test_support/world_fake/combat.rs b/gateway/src/world/test_support/world_fake/combat.rs index fd7798758..10f4d307f 100644 --- a/gateway/src/world/test_support/world_fake/combat.rs +++ b/gateway/src/world/test_support/world_fake/combat.rs @@ -8,10 +8,15 @@ pub(crate) struct CombatState { } impl CombatStore for WorldFake { - fn set_target(&self, _account_id: u64, _self_guid: u64, _target_guid: u64) -> Result<()> { + fn set_target(&self, _account_id: u64, _self_guid: u64, target_guid: u64) -> Result<()> { match &self.combat.set_target_error { Some(e) => Err(anyhow!("{e}")), - None => Ok(()), + None => { + if let Some(state) = &self.benilla_gameplay { + state.lock().unwrap().selected = target_guid; + } + Ok(()) + } } } diff --git a/gateway/src/world/test_support/world_fake/item.rs b/gateway/src/world/test_support/world_fake/item.rs index d66290ab4..8f42f1f63 100644 --- a/gateway/src/world/test_support/world_fake/item.rs +++ b/gateway/src/world/test_support/world_fake/item.rs @@ -1,7 +1,27 @@ use super::super::*; -/// Item behaviour is tested through `InMemoryItemActions`; every verb here succeeds. +/// Item actions shared by the family and Benilla World Session tests. impl ItemActionStore for WorldFake { + fn destroy_item( + &self, + _account_id: u64, + _self_guid: u64, + _slot: u8, + _count: u32, + ) -> Result { + Ok(lyracore_shared::item::ItemRefusal::ItemNotFound.into()) + } + fn split_item( + &self, + _account_id: u64, + _self_guid: u64, + _from_slot: u8, + _to_slot: u8, + _count: u32, + ) -> Result { + Ok(lyracore_shared::item::ItemRefusal::ItemNotFound.into()) + } + fn equip_item( &self, _account_id: u64, @@ -24,9 +44,19 @@ impl ItemActionStore for WorldFake { &self, _account_id: u64, _self_guid: u64, - _from_slot: u8, - _to_slot: u8, + from_slot: u8, + to_slot: u8, ) -> Result { + if let Some(state) = &self.benilla_gameplay { + let mut state = state.lock().unwrap(); + for item in &mut state.inventory { + if item.slot == from_slot { + item.slot = to_slot; + } else if item.slot == to_slot { + item.slot = from_slot; + } + } + } Ok(ItemActionResult::Done) } diff --git a/gateway/src/world/test_support/world_fake/loot_window.rs b/gateway/src/world/test_support/world_fake/loot_window.rs index 0d307c69d..1ac5ef504 100644 --- a/gateway/src/world/test_support/world_fake/loot_window.rs +++ b/gateway/src/world/test_support/world_fake/loot_window.rs @@ -17,6 +17,9 @@ pub(crate) struct LootWindowState { impl LootWindowStore for WorldFake { fn loot_target_money(&self, _target_guid: u64) -> Result { + if let Some(state) = &self.benilla_gameplay { + return Ok(state.lock().unwrap().corpse_money); + } Ok(self.loot_window.corpse_money) } @@ -25,6 +28,9 @@ impl LootWindowStore for WorldFake { _target_guid: u64, viewer_guid: u64, ) -> Result> { + if let Some(state) = &self.benilla_gameplay { + return Ok(state.lock().unwrap().loot.clone()); + } Ok(self .loot_window .corpse_loot_by_viewer @@ -76,13 +82,17 @@ impl LootWindowStore for WorldFake { .lock() .unwrap() .push(target_guid); + if let Some(state) = &self.benilla_gameplay { + let mut state = state.lock().unwrap(); + state.copper += std::mem::take(&mut state.corpse_money); + } Ok(LootWindowRequestStatus::Applied) } fn take_loot( &self, _account_id: u64, - _actor_guid: u64, + actor_guid: u64, target_guid: u64, loot_slot: u8, ) -> Result { @@ -91,6 +101,21 @@ impl LootWindowStore for WorldFake { .lock() .unwrap() .push((target_guid, loot_slot)); + if let Some(state) = &self.benilla_gameplay { + let mut state = state.lock().unwrap(); + if let Some(index) = state.loot.iter().position(|item| item.0 == loot_slot) { + let (_, entry, stack_count, _, random_property_id) = state.loot.remove(index); + state.inventory.push(codec::ItemInstanceView { + guid: 0x4000_0000_0000_0000 | (u64::from(loot_slot) + 1), + owner_guid: actor_guid, + entry, + stack_count, + random_property_id, + slot: 23, + ..Default::default() + }); + } + } Ok(LootWindowRequestStatus::Applied) } } diff --git a/gateway/src/world/test_support/world_fake/melee.rs b/gateway/src/world/test_support/world_fake/melee.rs index 72d8dc1db..55141949d 100644 --- a/gateway/src/world/test_support/world_fake/melee.rs +++ b/gateway/src/world/test_support/world_fake/melee.rs @@ -3,11 +3,17 @@ use super::super::*; /// Melee behaviour is tested through `InMemoryMeleeActions`. The shard routing tests read the /// `start_attack` entry in the topology call log, so that one call stays recorded. impl MeleeActionStore for WorldFake { - fn start_attack(&self, _account_id: u64, _self_guid: u64, _target_guid: u64) -> Result<()> { + fn start_attack(&self, _account_id: u64, _self_guid: u64, target_guid: u64) -> Result<()> { self.rec("start_attack"); + if let Some(state) = &self.benilla_gameplay { + state.lock().unwrap().attacking = Some(target_guid); + } Ok(()) } fn stop_attack(&self, _account_id: u64, _self_guid: u64) -> Result<()> { + if let Some(state) = &self.benilla_gameplay { + state.lock().unwrap().attacking = None; + } Ok(()) } } diff --git a/gateway/src/world/test_support/world_fake/mod.rs b/gateway/src/world/test_support/world_fake/mod.rs index 033a9c170..dbe4f62de 100644 --- a/gateway/src/world/test_support/world_fake/mod.rs +++ b/gateway/src/world/test_support/world_fake/mod.rs @@ -60,6 +60,8 @@ use crate::world::codec; /// Each family keeps its state in its own field, defined beside that family's impl. #[derive(Default)] pub(crate) struct WorldFake { + pub(crate) benilla_gameplay: + Option>, /// The Characters this Shard holds. Several families read it. pub(crate) characters: Vec, /// 195: `npc_refuses_interaction` return — false (derive-Default) keeps every fixture NPC open. diff --git a/gateway/src/world/test_support/world_fake/npc.rs b/gateway/src/world/test_support/world_fake/npc.rs index a3fdd8b16..77c69ab0a 100644 --- a/gateway/src/world/test_support/world_fake/npc.rs +++ b/gateway/src/world/test_support/world_fake/npc.rs @@ -49,11 +49,21 @@ impl NpcStore for WorldFake { Ok(self.npc_refuses) // default false — every existing fixture NPC keeps interacting } - fn bind_home(&self, _account_id: u64, _self_guid: u64) -> Result<()> { + fn bind_home( + &self, + _account_id: u64, + _self_guid: u64, + _innkeeper_guid: u64, + ) -> Result { + if !self.npc.innkeeper { + return Ok(InteractionOutcome::Refused( + "target is not an innkeeper".into(), + )); + } self.npc .home_bound .store(true, std::sync::atomic::Ordering::SeqCst); - Ok(()) + Ok(InteractionOutcome::Done) } fn npc_is_innkeeper(&self, _guid: u64) -> Result { diff --git a/gateway/src/world/test_support/world_fake/quest.rs b/gateway/src/world/test_support/world_fake/quest.rs index 5704b252e..7e91bc2b5 100644 --- a/gateway/src/world/test_support/world_fake/quest.rs +++ b/gateway/src/world/test_support/world_fake/quest.rs @@ -27,7 +27,17 @@ impl QuestActionStore for WorldFake { _giver_guid: u64, _player_guid: u64, ) -> Result> { - Ok(self.quest.quest_evals.clone()) + let mut evaluations = self.quest.quest_evals.clone(); + if self.benilla_gameplay.is_some() { + let log = self.quest.quest_log.lock().unwrap(); + for evaluation in &mut evaluations { + if evaluation.role == codec::ROLE_END { + evaluation.active = log.contains(&(evaluation.quest_id, false)); + evaluation.complete &= evaluation.active; + } + } + } + Ok(evaluations) } fn quest_detail_view(&self, quest_id: u32) -> Result> { @@ -43,16 +53,19 @@ impl QuestActionStore for WorldFake { Ok(self.npc_refuses) } - /// Accept, abandon and share are driven only at the quest seam, where - /// `InMemoryQuestActions` records the request — a socket test would prove nothing more, so - /// this store just answers success. fn accept_quest( &self, _account_id: u64, _self_guid: u64, _giver_guid: u64, - _quest_id: u32, + quest_id: u32, ) -> Result<()> { + if self.benilla_gameplay.is_some() { + let mut log = self.quest.quest_log.lock().unwrap(); + if !log.iter().any(|&(id, _)| id == quest_id) { + log.push((quest_id, false)); + } + } Ok(()) } @@ -98,6 +111,20 @@ impl QuestActionStore for WorldFake { if let Some(e) = &self.trade_error { return Err(anyhow!("{e}")); } + if let Some(state) = &self.benilla_gameplay { + let mut log = self.quest.quest_log.lock().unwrap(); + let Some((_, rewarded)) = log.iter_mut().find(|(id, _)| *id == quest_id) else { + return Err(anyhow!("quest not active")); + }; + if *rewarded { + return Err(anyhow!("quest already rewarded")); + } + let detail = self + .quest_detail_view(quest_id)? + .ok_or_else(|| anyhow!("quest absent"))?; + state.lock().unwrap().copper += detail.money_reward; + *rewarded = true; + } self.quest .turned_in .lock() diff --git a/gateway/src/world/test_support/world_fake/session.rs b/gateway/src/world/test_support/world_fake/session.rs index 547602938..988bb0200 100644 --- a/gateway/src/world/test_support/world_fake/session.rs +++ b/gateway/src/world/test_support/world_fake/session.rs @@ -5,6 +5,8 @@ pub(crate) type MoveRecord = (u32, f32, f32, f32, f32, u32); #[derive(Default)] pub(crate) struct SessionState { + pub(crate) movement_world: + Option>, /// WORLDPORT_ACK gate: true = entity present -> a spurious ack is ignored; /// false (derive-Default) = absent -> a genuine transfer is pending. pub(crate) entity_in_world: bool, @@ -112,7 +114,7 @@ impl SessionStore for WorldFake { fn movement_update( &self, _account_id: u64, - _self_guid: u64, + self_guid: u64, opcode: u32, info: &MovementInfo, ) -> Result<()> { @@ -128,6 +130,9 @@ impl SessionStore for WorldFake { info.orientation, info.timestamp, )); + if let Some(world) = &self.session.movement_world { + world.update(self_guid, opcode, info)?; + } Ok(()) } diff --git a/gateway/src/world/test_support/world_fake/trainer.rs b/gateway/src/world/test_support/world_fake/trainer.rs index ad6992ba1..153cebb69 100644 --- a/gateway/src/world/test_support/world_fake/trainer.rs +++ b/gateway/src/world/test_support/world_fake/trainer.rs @@ -2,6 +2,9 @@ use super::super::*; #[derive(Default)] pub(crate) struct TrainerState { + pub(crate) reputation_at_war: std::sync::Mutex>, + pub(crate) talent_reset_quote: Option, + pub(crate) reset_talents_refusal: Option, /// Spelled as a refusal so derive-Default (false) keeps every fixture trainer serving; the /// trait method reads the negation. pub(crate) trainer_refuses_class: bool, @@ -11,6 +14,10 @@ pub(crate) struct TrainerState { } impl TrainerStore for WorldFake { + fn talent_reset_cost(&self, _character_guid: u64) -> Option { + self.trainer.talent_reset_quote + } + fn trainer_serves(&self, _player_guid: u64, _trainer_guid: u64) -> Result { Ok(!self.trainer.trainer_refuses_class) // default true — every existing fixture trainer serves } @@ -41,10 +48,15 @@ impl TrainerStore for WorldFake { &self, _account_id: u64, _self_guid: u64, - _reputation_index: u32, - _at_war: bool, - ) -> Result<()> { - Ok(()) + reputation_index: u32, + at_war: bool, + ) -> Result { + self.trainer + .reputation_at_war + .lock() + .unwrap() + .insert(reputation_index, at_war); + Ok(InteractionOutcome::Done) } fn set_action_button( @@ -70,13 +82,21 @@ impl TrainerStore for WorldFake { Ok(()) } - fn reset_talents(&self, account_id: u64, self_guid: u64, trainer_guid: u64) -> Result<()> { + fn reset_talents( + &self, + account_id: u64, + self_guid: u64, + trainer_guid: u64, + ) -> Result { + if let Some(e) = &self.trainer.reset_talents_refusal { + return Ok(InteractionOutcome::Refused(e.clone())); + } self.trainer.reset_talents_calls.lock().unwrap().push(( account_id, self_guid, trainer_guid, )); - Ok(()) + Ok(InteractionOutcome::Done) } fn resolve_learn_target(&self, spell_id: u32) -> u32 { diff --git a/gateway/src/world/tests.rs b/gateway/src/world/tests.rs index 33034fcff..2a1355ca8 100644 --- a/gateway/src/world/tests.rs +++ b/gateway/src/world/tests.rs @@ -111,6 +111,8 @@ mod alpha_test_tools_tests; mod auction_tests; #[path = "bank_tests.rs"] mod bank_tests; +#[path = "benilla_tests.rs"] +pub(crate) mod benilla_tests; #[path = "cast_tests.rs"] mod cast_tests; #[path = "character_tests.rs"] diff --git a/gateway/src/world/trainer_tests.rs b/gateway/src/world/trainer_tests.rs index 653ec22a5..f019262f1 100644 --- a/gateway/src/world/trainer_tests.rs +++ b/gateway/src/world/trainer_tests.rs @@ -157,7 +157,7 @@ impl TrainerStore for TrainerFake { _self_guid: u64, _reputation_index: u32, _at_war: bool, - ) -> Result<()> { + ) -> Result { unimplemented!("set_faction_at_war") } @@ -173,7 +173,16 @@ impl TrainerStore for TrainerFake { Ok(()) } - fn reset_talents(&self, _account_id: u64, _self_guid: u64, _trainer_guid: u64) -> Result<()> { + fn talent_reset_cost(&self, _character_guid: u64) -> Option { + unimplemented!("talent_reset_cost") + } + + fn reset_talents( + &self, + _account_id: u64, + _self_guid: u64, + _trainer_guid: u64, + ) -> Result { unimplemented!("reset_talents") } diff --git a/gateway/tests/schema_parity.rs b/gateway/tests/schema_parity.rs index d43816669..5eb3466d5 100644 --- a/gateway/tests/schema_parity.rs +++ b/gateway/tests/schema_parity.rs @@ -542,7 +542,7 @@ parity_test!(parity_game_addon_message, "game_addon_message", lyracore_module::A id, recipient_identity, cmd, payload, created_at, }); parity_test!(parity_game_xp_event, "game_xp_event", lyracore_module::XpEvent, bindings::xp_event_type::XpEvent, { - id, recipient_identity, killed_guid, total_exp, created_at, is_kill, + id, recipient_identity, killed_guid, total_exp, created_at, is_kill, rested_bonus, }); parity_test!(parity_game_levelup_event, "game_levelup_event", lyracore_module::LevelupEvent, bindings::levelup_event_type::LevelupEvent, { id, recipient_identity, new_level, health_gained, created_at, mana_gained, strength_gained, agility_gained, stamina_gained, intellect_gained, spirit_gained, diff --git a/module/src/exploration.rs b/module/src/exploration.rs index 56e41f886..7a5017d3e 100644 --- a/module/src/exploration.rs +++ b/module/src/exploration.rs @@ -131,6 +131,7 @@ pub(crate) fn check_area_exploration( total_exp: xp, created_at: ctx.timestamp, is_kill: false, + rested_bonus: 0, }); } } diff --git a/module/src/gw.rs b/module/src/gw.rs index 55f8e6fcb..a5cae79a1 100644 --- a/module/src/gw.rs +++ b/module/src/gw.rs @@ -1115,6 +1115,36 @@ pub fn gw_move_item( .map_err(crate::items::refused) } +/// Destroy an item owned by the Character holding this World Session. +#[reducer] +pub fn gw_destroy_item( + ctx: &ReducerContext, + request_actor: crate::SessionActor, + slot: u8, + count: u32, +) -> Result<(), String> { + require_operator(ctx)?; + let actor_guid = crate::account_ownership::require_actor(ctx, request_actor)?; + item_actor(ctx, actor_guid)?; + crate::items::apply_item_destroy(ctx, actor_guid, slot, count).map_err(crate::items::refused) +} + +/// Split a stack owned by the Character holding this World Session. +#[reducer] +pub fn gw_split_item( + ctx: &ReducerContext, + request_actor: crate::SessionActor, + from_slot: u8, + to_slot: u8, + count: u32, +) -> Result<(), String> { + require_operator(ctx)?; + let actor_guid = crate::account_ownership::require_actor(ctx, request_actor)?; + item_actor(ctx, actor_guid)?; + crate::items::apply_item_split(ctx, actor_guid, from_slot, count, to_slot) + .map_err(crate::items::refused) +} + /// [`crate::items::apply_unequip_item`] with the owner named by guid. #[reducer] pub fn gw_unequip_item( @@ -1225,17 +1255,17 @@ pub fn gw_fish(ctx: &ReducerContext, request_actor: crate::SessionActor) -> Resu crate::professions::apply_fish(ctx, actor_guid) } -/// [`crate::world::set_home`] with the binder named by guid — the innkeeper hearth bind. The bind -/// names no NPC, so ungated it would let any client hearth anywhere on the map. +/// Bind the Character's home after checking the selected innkeeper. #[reducer] pub fn gw_bind_home( ctx: &ReducerContext, request_actor: crate::SessionActor, + innkeeper_guid: u64, ) -> Result<(), String> { require_operator(ctx)?; let actor_guid = crate::account_ownership::require_actor(ctx, request_actor)?; actor(ctx, actor_guid)?; - crate::items::innkeeper_access_gate(ctx, actor_guid)?; + crate::items::innkeeper_access_gate(ctx, actor_guid, innkeeper_guid)?; crate::world::set_home(ctx, actor_guid); Ok(()) } diff --git a/module/src/items/economy.rs b/module/src/items/economy.rs index 62aaf4fb6..6f1f3f8aa 100644 --- a/module/src/items/economy.rs +++ b/module/src/items/economy.rs @@ -79,16 +79,25 @@ pub(crate) fn bank_access_gate(ctx: &ReducerContext, player_guid: u64) -> Result ) } -/// The hearth-bind trust gate: binding a home needs a live INNKEEPER npc in reach. -pub(crate) fn innkeeper_access_gate(ctx: &ReducerContext, player_guid: u64) -> Result<(), String> { - use lyracore_shared::constants::npc_flags; - flagged_npc_in_reach_gate( +/// Binding a home requires the selected innkeeper to be alive and in reach. +pub(crate) fn innkeeper_access_gate( + ctx: &ReducerContext, + character_guid: u64, + innkeeper_guid: u64, +) -> Result<(), String> { + let (_, innkeeper) = npc_interaction_gate( ctx, - player_guid, - npc_flags::INNKEEPER, + character_guid, + innkeeper_guid, + lyracore_shared::constants::npc_flags::INNKEEPER, "innkeeper", + "target is not an innkeeper", "bind a home", - ) + )?; + if innkeeper.dead || innkeeper.health == 0 { + return Err("innkeeper is dead".into()); + } + Ok(()) } /// Is the player alive with a live NPC carrying `flag` inside the interaction radius? The gate for @@ -280,6 +289,11 @@ pub(crate) fn apply_item_sell( let instances = ctx.db.game_item_instance(); let inst = item_in_slot(ctx, player_guid, slot).ok_or_else(|| format!("no item in slot {slot}"))?; + if crate::trade::item_is_offered(ctx, player_guid, inst.guid) { + return Err(super::refused( + lyracore_shared::item::ItemRefusal::NotRightNow, + )); + } // Only BACKPACK/bag items are sellable. Refuse an EQUIPPED item (slots 0..=18): vanilla's vendor // window can't target worn gear, so a client must not be able to sell items off the body. if inst.slot <= equip_slot::END { @@ -568,7 +582,7 @@ mod tests { buy_bank_slot_result, buyback_newest_first, buyback_ring_full, flagged_npc_in_reach, BuyBankSlotOutcome, VENDOR_RANGE_SQ, }; - use lyracore_shared::constants::npc_flags::{BANKER, INNKEEPER, VENDOR}; + use lyracore_shared::constants::npc_flags::{BANKER, VENDOR}; /// BANK ACCESS: only a live BANKER-flagged NPC inside the interaction radius opens the bank. A /// player, a corpse, a non-banker NPC, and a banker one yard too far all leave it shut — so a move @@ -587,20 +601,6 @@ mod tests { assert!(!banker_in_reach(false, BANKER, false, edge + 1.0)); // out of range } - /// HEARTH BIND: the same gate, keyed on INNKEEPER — a distant innkeeper must refuse the bind. - #[test] - fn innkeeper_in_reach_refuses_a_distant_or_wrong_kind_npc() { - let edge = VENDOR_RANGE_SQ; - let inn_in_reach = - |is_player, flags, dead, d| flagged_npc_in_reach(INNKEEPER, is_player, flags, dead, d); - assert!(inn_in_reach(false, INNKEEPER, false, 0.0)); - assert!(inn_in_reach(false, INNKEEPER | VENDOR, false, edge)); - assert!(!inn_in_reach(false, INNKEEPER, false, edge + 1.0)); // across the room, not at the bar - assert!(!inn_in_reach(false, BANKER, false, 0.0)); // wrong npc kind - assert!(!inn_in_reach(true, INNKEEPER, false, 0.0)); // another player - assert!(!inn_in_reach(false, INNKEEPER, true, 0.0)); // an innkeeper's corpse - } - /// 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] diff --git a/module/src/items/exchange.rs b/module/src/items/exchange.rs index 90bf04926..929d211a0 100644 --- a/module/src/items/exchange.rs +++ b/module/src/items/exchange.rs @@ -240,6 +240,19 @@ impl ItemStoragePlan { } pub(super) fn commit(self, ctx: &ReducerContext) -> Result<(), ActionRefusal> { + let mut changed_offer = false; + for (item, &(count, soulbound)) in self.items.iter().zip(&self.original_stacks) { + if (item.stack_count, item.soulbound) != (count, soulbound) + && crate::trade::item_is_offered(ctx, self.character_guid, item.guid) + { + if item.stack_count < count { + return Err( + super::refused(lyracore_shared::item::ItemRefusal::NotRightNow).into(), + ); + } + changed_offer = true; + } + } let mut guids = allocate_item_guids(ctx, self.items.len() - self.original_stacks.len())?.into_iter(); let instances = ctx.db.game_item_instance(); @@ -262,6 +275,9 @@ impl ItemStoragePlan { } } } + if changed_offer { + crate::trade::refresh_offer(ctx, self.character_guid); + } Ok(()) } } diff --git a/module/src/items/inventory.rs b/module/src/items/inventory.rs index cf696f075..6b275266f 100644 --- a/module/src/items/inventory.rs +++ b/module/src/items/inventory.rs @@ -20,8 +20,64 @@ use super::tables::{ }; 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))] +fn bag_has_contents(ctx: &ReducerContext, character_guid: u64, slot: u8) -> bool { + if !(BAG_SLOT_START..=BAG_SLOT_END_INCL).contains(&slot) { + return false; + } + let start = BAG_CONTENT_OFFSET + (slot - BAG_SLOT_START) * MAX_BAG_SIZE; + ctx.db + .game_item_instance() + .by_owner_guid() + .filter(&character_guid) + .any(|item| (start..start + MAX_BAG_SIZE).contains(&item.slot)) +} + +/// Destroy only the addressed owned stack. Zero means the whole stack. +pub(crate) fn apply_item_destroy( + ctx: &ReducerContext, + character_guid: u64, + slot: u8, + count: u32, +) -> Result<(), ItemRefusal> { + let character = crate::helpers::live_entity(ctx, character_guid) + .map_err(|_| refuse(ItemRefusal::Internal, "Character is not in world"))?; + if character.health == 0 { + return Err(ItemRefusal::PlayerDead); + } + if is_bank_slot(slot) { + bank_access(ctx, character_guid)?; + } + let mut item = item_in_slot(ctx, character_guid, slot).ok_or(ItemRefusal::ItemNotFound)?; + if crate::trade::item_is_offered(ctx, character_guid, item.guid) { + return Err(ItemRefusal::NotRightNow); + } + let template = ctx + .db + .game_item_template() + .entry() + .find(item.entry) + .ok_or(ItemRefusal::ItemNotFound)?; + if template.item_flags & 0x20 != 0 { + return Err(ItemRefusal::Indestructible); + } + if bag_has_contents(ctx, character_guid, slot) { + return Err(ItemRefusal::BagNotEmpty); + } + if count == 0 || count >= item.stack_count { + ctx.db.game_item_instance().guid().delete(item.guid); + } else { + item.stack_count -= count; + ctx.db.game_item_instance().guid().update(item); + } + if slot <= equip_slot::END { + crate::spell::recompute_vitals(ctx, character_guid); + crate::spell::recompute_sheet(ctx, character_guid); + } + Ok(()) +} + +/// Split a proper subset into empty storage, preserving ownership and binding state. +/// Equipment and bag equipment slots cannot receive a split stack. pub(crate) fn apply_item_split( ctx: &ReducerContext, player_guid: u64, @@ -36,16 +92,14 @@ pub(crate) fn apply_item_split( let instances = ctx.db.game_item_instance(); let mut inst = item_in_slot(ctx, player_guid, slot) .ok_or_else(|| refuse(ItemRefusal::ItemNotFound, format!("no item in slot {slot}")))?; + if crate::trade::item_is_offered(ctx, player_guid, inst.guid) { + return Err(ItemRefusal::NotRightNow); + } // A split must leave at least one unit in BOTH slots — splitting off none or the whole stack isn't // a split (the latter is a move). if !valid_split_count(count, inst.stack_count) { return Err(refuse(ItemRefusal::WrongSlot, "invalid split count")); } - // Reject an out-of-range destination (anti-overflow; same phantom-slot dupe vector as apply_item_move) - // AND reject an equipment-region destination (0..=18): a split can never legitimately target the - // body — unlike apply_item_move it runs no can_equip_into/proficiency/required-level/BoE check, so - // admitting 0..=18 here let a modified client CMSG_SPLIT_ITEM a stack straight into an empty head or - // off-hand slot, bypassing every gate the move path enforces for that region. if !valid_split_dest_slot(to_slot) { return Err(refuse( ItemRefusal::WrongSlot, @@ -85,15 +139,8 @@ pub(crate) fn apply_item_split( Ok(()) } -/// Shared move/swap logic for the player + debug paths: move the item in `from_slot` to `to_slot`. -/// If `to_slot` holds an item too, the two SWAP slots; if it's empty, the item just moves. The item -/// GUID remains stable when its inventory slot changes. -/// One EXCEPTION to the swap (FEATURE B): if the destination holds the SAME stackable item -/// (`dst.entry == src.entry` and the template's `max_stack > 1`), the stacks MERGE instead of swapping -/// — `merge_amount` units flow from src into dst (capped by dst's headroom); src is deleted if drained, -/// else left with the remainder. A non-matching or non-stackable destination keeps the SWAP byte-for-byte. -/// Errors if `from_slot` is empty. A no-op when the slots are equal. Additive — touches only the two -/// item rows' `slot`/`stack_count` (and may delete the drained src on a full merge). [entity] +/// Move, swap or merge two owned stacks after both destinations pass their Gates. +/// Nonempty equipped bags stay in place until contents can follow a bag-slot change. pub(crate) fn apply_item_move( ctx: &ReducerContext, player_guid: u64, @@ -103,7 +150,7 @@ pub(crate) fn apply_item_move( if from_slot == to_slot { return Ok(()); } - // The bank is a place, not a portable bag: either endpoint in bank space needs an open bank. + // Bank access applies to either endpoint. if is_bank_slot(from_slot) || is_bank_slot(to_slot) { bank_access(ctx, player_guid)?; } @@ -114,140 +161,149 @@ pub(crate) fn apply_item_move( format!("no item in slot {from_slot}"), ) })?; - // Reject an out-of-range destination. Valid slots: equipment 0..=18, bag-equip 19..=22, - // backpack 23..=38, or a bag-content slot (120..=191) for items landing inside an equipped bag. - // Anything outside these ranges (e.g. 39..=119 = bank/keyring we don't model, or 192..255) is - // an inventory-overflow dupe vector from a modified client and is rejected. - if !valid_dest_slot(to_slot) { - return Err(refuse( - ItemRefusal::WrongSlot, - format!("invalid destination slot {to_slot}"), - )); - } - // If the destination is in the bag-content region, validate that the corresponding bag is - // equipped and the slot is within its capacity (prevents stashing items in unequipped bags). - validate_bag_dest_slot(ctx, player_guid, to_slot)?; - // Equip-validation: moving INTO any EQUIPMENT slot (0..=18) requires the SOURCE item's - // `inventory_type` map to that slot (`can_equip_into`). A missing template fails closed (we can't - // prove it's wearable). Slot 15 (main-hand) defers to the stricter `can_equip_mainhand` rule inside - // `can_equip_into`; the other 18 slots use the general resolver. Non-equipment destinations (>18: - // bag/backpack/bank) don't reach this branch — the move/swap/merge below run unconditionally. - if to_slot <= equip_slot::END { - // Dual Wield: a caster who has LEARNED spell 674 may equip a second one-hander into OFFHAND — - // `can_equip_into` only accepts that combination when this is true. - let can_dual_wield = crate::spell::knows_spell( - ctx, - player_guid, - lyracore_shared::constants::dual_wield::SPELL_ID, - ); - let tmpl = match ctx.db.game_item_template().entry().find(src.entry) { - Some(tmpl) - if can_equip_into(tmpl.class, tmpl.inventory_type, to_slot, can_dual_wield) => - { - tmpl - } - _ => { - return Err(refuse( - ItemRefusal::CannotEquip, - format!("cannot equip that item in slot {to_slot}"), - )) - } - }; - // Required-level gate: you can carry a too-high item in the bag, but can't EQUIP it. Read the - // player entity just for its level here (the move path doesn't otherwise need it); a missing - // entity fails closed. Seeded items are required_level 1, so this never trips for the loadout. - let player = crate::helpers::live_entity(ctx, player_guid) - .map_err(|_| refuse(ItemRefusal::Internal, "user not in world"))?; - // Vanilla's level result carries the required level in the packet, and a Refusal tag has no - // payload, so a level Gate reads as the generic equip failure. - if !meets_required_level(player.level, tmpl.required_level) { - return Err(refuse( - ItemRefusal::CannotEquip, - format!("requires level {}", tmpl.required_level), - )); + let mut destination = item_in_slot(ctx, player_guid, to_slot); + // Check both placements before storing either item. + for (src, to_slot) in + std::iter::once((&mut src, to_slot)).chain(destination.as_mut().map(|dst| (dst, from_slot))) + { + if crate::trade::item_is_offered(ctx, player_guid, src.guid) { + return Err(ItemRefusal::NotRightNow); } - // Proficiency gate: enforce class armor/weapon restrictions (e.g. a Mage can't equip - // plate). The class is byte 1 of unit_bytes_0 (race | class<<8 | gender<<16 | power<<24). - // Creatures (class 0) never call equip_item; fail closed for unknown classes. - let player_class = player.class(); - if !eligibility_mask_allows(tmpl.allowed_class, player_class) { + if !valid_dest_slot(to_slot) { return Err(refuse( - ItemRefusal::NoProficiency, - format!("class {player_class} is not allowed to equip this item"), + ItemRefusal::WrongSlot, + format!("invalid destination slot {to_slot}"), )); } - let player_race = player.race(); - if !eligibility_mask_allows(tmpl.allowed_race, player_race) { - return Err(refuse( - ItemRefusal::NoProficiency, - format!("race {player_race} is not allowed to equip this item"), - )); + validate_bag_dest_slot(ctx, player_guid, to_slot)?; + if bag_has_contents(ctx, player_guid, src.slot) { + return Err(ItemRefusal::BagNotEmpty); } - let current_skill = ctx - .db - .game_player_skill() - .by_character() - .filter(&player_guid) - .find(|skill| skill.skill_line == tmpl.required_skill) - .map(|skill| skill.current); - if !meets_required_skill(tmpl.required_skill, tmpl.required_skill_rank, current_skill) { - return Err(refuse( - ItemRefusal::RequiredSkill, - format!( - "requires skill {} at rank {}", - tmpl.required_skill, tmpl.required_skill_rank - ), - )); - } - let reputation_standing = ctx - .db - .game_player_reputation() - .by_character() - .filter(&player_guid) - .find(|reputation| reputation.faction_id == tmpl.required_reputation_faction) - .map(|reputation| reputation.standing); - if !meets_required_reputation( - tmpl.required_reputation_faction, - tmpl.required_reputation_rank, - reputation_standing, - ) { - return Err(refuse( - ItemRefusal::RequiredReputation, - format!( - "requires reputation faction {} at rank {}", - tmpl.required_reputation_faction, tmpl.required_reputation_rank - ), - )); + if (BAG_SLOT_START..=BAG_SLOT_END_INCL).contains(&src.slot) { + let start = BAG_CONTENT_OFFSET + (src.slot - BAG_SLOT_START) * MAX_BAG_SIZE; + if (start..start + MAX_BAG_SIZE).contains(&to_slot) { + return Err(ItemRefusal::WrongSlot); + } } - // Armor proficiency is class base set PLUS the two upgrades a class trainer teaches at 40: - // knowing the passive IS the proficiency, so an untrained Warrior wears mail but not plate. - // Same derivation the gateway masks into SMSG_SET_PROFICIENCY, so the tint cannot promise - // what this Gate refuses. - let proficiency = Proficiency::from_spellbook(player_class, |spell_id| { - crate::spell::knows_spell(ctx, player_guid, spell_id) - }); - if !proficiency.can_equip(tmpl.class, tmpl.subclass) { - return Err(refuse( - ItemRefusal::NoProficiency, - format!( - "class {} lacks proficiency for item class {}/subclass {}", - player_class, tmpl.class, tmpl.subclass - ), - )); + if to_slot <= BAG_SLOT_END_INCL { + let can_dual_wield = crate::spell::knows_spell( + ctx, + player_guid, + lyracore_shared::constants::dual_wield::SPELL_ID, + ); + let tmpl = ctx + .db + .game_item_template() + .entry() + .find(src.entry) + .ok_or(ItemRefusal::CannotEquip)?; + let fits = if to_slot >= BAG_SLOT_START { + matches!(tmpl.class, 1 | 11) + && tmpl.inventory_type == invtype::BAG + && tmpl.container_slots > 0 + } else { + can_equip_into(tmpl.class, tmpl.inventory_type, to_slot, can_dual_wield) + }; + if !fits { + return Err(ItemRefusal::CannotEquip); + } + let player = crate::helpers::live_entity(ctx, player_guid) + .map_err(|_| refuse(ItemRefusal::Internal, "user not in world"))?; + if !meets_required_level(player.level, tmpl.required_level) { + return Err(refuse( + ItemRefusal::CannotEquip, + format!("requires level {}", tmpl.required_level), + )); + } + let player_class = player.class(); + if !eligibility_mask_allows(tmpl.allowed_class, player_class) { + return Err(refuse( + ItemRefusal::NoProficiency, + format!("class {player_class} is not allowed to equip this item"), + )); + } + let player_race = player.race(); + if !eligibility_mask_allows(tmpl.allowed_race, player_race) { + return Err(refuse( + ItemRefusal::NoProficiency, + format!("race {player_race} is not allowed to equip this item"), + )); + } + let current_skill = ctx + .db + .game_player_skill() + .by_character() + .filter(&player_guid) + .find(|skill| skill.skill_line == tmpl.required_skill) + .map(|skill| skill.current); + if !meets_required_skill(tmpl.required_skill, tmpl.required_skill_rank, current_skill) { + return Err(refuse( + ItemRefusal::RequiredSkill, + format!( + "requires skill {} at rank {}", + tmpl.required_skill, tmpl.required_skill_rank + ), + )); + } + let reputation_standing = ctx + .db + .game_player_reputation() + .by_character() + .filter(&player_guid) + .find(|reputation| reputation.faction_id == tmpl.required_reputation_faction) + .map(|reputation| reputation.standing); + if !meets_required_reputation( + tmpl.required_reputation_faction, + tmpl.required_reputation_rank, + reputation_standing, + ) { + return Err(refuse( + ItemRefusal::RequiredReputation, + format!( + "requires reputation faction {} at rank {}", + tmpl.required_reputation_faction, tmpl.required_reputation_rank + ), + )); + } + let proficiency = Proficiency::from_spellbook(player_class, |spell_id| { + crate::spell::knows_spell(ctx, player_guid, spell_id) + }); + if !proficiency.can_equip(tmpl.class, tmpl.subclass) { + return Err(refuse( + ItemRefusal::NoProficiency, + format!( + "class {} lacks proficiency for item class {}/subclass {}", + player_class, tmpl.class, tmpl.subclass + ), + )); + } + if binds_on_equip(tmpl.bonding) { + src.soulbound = true; + } } - // BoE: a Bind-on-Equip item binds the FIRST time it lands on the body — not on pickup. Every - // equip lands here (apply_equip_item and a direct manual move both route through this branch), - // so this is the single BoE binding trigger. Idempotent: an already-bound item just stays - // bound (no-op re-equip / re-swap). - if binds_on_equip(tmpl.bonding) { - src.soulbound = true; + } + if [from_slot, to_slot] + .iter() + .any(|slot| matches!(*slot, equip_slot::MAINHAND | equip_slot::OFFHAND)) + { + let entry_after_move = |slot| { + if to_slot == slot { + Some(src.entry) + } else if from_slot == slot { + destination.as_ref().map(|item| item.entry) + } else { + item_in_slot(ctx, player_guid, slot).map(|item| item.entry) + } + }; + if entry_after_move(equip_slot::OFFHAND).is_some() + && entry_after_move(equip_slot::MAINHAND) + .and_then(|entry| ctx.db.game_item_template().entry().find(entry)) + .is_some_and(|template| template.inventory_type == invtype::TWO_HAND_WEAPON) + { + return Err(ItemRefusal::CannotEquip); } } - // If the destination is occupied, either MERGE (same stackable item) or SWAP (anything else). - if let Some(mut dst) = item_in_slot(ctx, player_guid, to_slot) { - // MERGE only when dropping onto the SAME entry AND the item is stackable (max_stack > 1). - // A missing template can't be merged (we can't know max_stack) — fall through to SWAP, which - // needs no template, so the move never wedges on unseeded item data. + if let Some(mut dst) = destination { if dst.entry == src.entry && dst.random_property_id == src.random_property_id { if let Some(tmpl) = ctx.db.game_item_template().entry().find(src.entry) { if tmpl.max_stack > 1 { @@ -256,31 +312,21 @@ pub(crate) fn apply_item_move( dst.stack_count += moved; instances.guid().update(dst); if moved >= src.stack_count { - // The whole source stack flowed into dst → remove the now-empty src row. instances.guid().delete(src.guid); } else { - // Partial merge (dst hit max_stack) → leave the remainder in src, in place. src.stack_count -= moved; instances.guid().update(src); } } - // `moved == 0` (dst already full) is a no-op: both stacks stay exactly as they were, - // which matches vanilla refusing to merge into a full stack (no swap, no error). return Ok(()); } } } - // Not a same-item stackable pair → the original SWAP: dst falls back into the source slot. dst.slot = from_slot; instances.guid().update(dst); } src.slot = to_slot; instances.guid().update(src); - // 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 - // is a no-op for non-players + when the derived max is unchanged.) if from_slot <= equip_slot::END || to_slot <= equip_slot::END { crate::spell::recompute_vitals(ctx, player_guid); crate::spell::recompute_sheet(ctx, player_guid); @@ -497,16 +543,9 @@ pub(crate) fn is_carried_slot(slot: u8) -> bool { || (BAG_CONTENT_OFFSET..BAG_CONTENT_END).contains(&slot) // 120..=191 } -// Live only under `debug_reducers`: `apply_item_split` is its sole caller. -#[cfg_attr(not(feature = "debug_reducers"), allow(dead_code))] -/// The destination-slot gate for `apply_item_split` ONLY: everything `valid_dest_slot` admits, MINUS -/// the equipment region (0..=`equip_slot::END`, i.e. 0..=18). A split can never legitimately place an -/// item on the body — `apply_item_move` is the only path that runs equip-validation -/// (`can_equip_into`/proficiency/required-level/BoE), and a split has no source-template check at all, -/// so admitting 0..=18 here was a total bypass of that region's gates. Splits only ever land in -/// bag-equip/backpack/bag-content space. +/// Splits target storage slots only. Equipment requires the move path's Gates. pub(crate) fn valid_split_dest_slot(to_slot: u8) -> bool { - valid_dest_slot(to_slot) && to_slot > equip_slot::END + valid_dest_slot(to_slot) && to_slot > BAG_SLOT_END_INCL } /// Decompose a bag-content slot (120..=191) into `(bag_idx, slot_in_bag)`: which of the four equipped @@ -568,8 +607,7 @@ fn bank_access(ctx: &ReducerContext, player_guid: u64) -> Result<(), ItemRefusal .map_err(|detail| refuse(ItemRefusal::BankUnavailable, detail)) } -// 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 each stack. pub(crate) fn valid_split_count(count: u32, stack_count: u32) -> bool { count != 0 && count < stack_count } @@ -726,8 +764,8 @@ pub(crate) fn apply_auto_bank_item( #[cfg(test)] mod tests { use super::{ - 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, + bag_content_decompose, is_bank_slot, is_carried_slot, valid_dest_slot, valid_split_count, + valid_split_dest_slot, BANK_SLOT_END_INCL, BANK_SLOT_START, }; /// BANK SLOT RANGE: exactly the 24 base bank slots (39..=62). The bank-bag ordinals just past them @@ -798,35 +836,18 @@ mod tests { assert!(!valid_dest_slot(255)); } - /// SPLIT DESTINATION GATE (regression): a split can never legitimately target the body, so - /// `valid_split_dest_slot` must refuse every equipment-region slot (0..=18) even though - /// `valid_dest_slot` alone admits it (0..=38 is the modeled equip+bag-equip+backpack range). Every - /// non-equipment slot `valid_dest_slot` admits stays admitted. #[test] - fn valid_split_dest_slot_refuses_the_equipment_region() { - // The whole equipment region (0..=18, e.g. HEAD=0..=TABARD=18) is valid for a plain move/dest - // check but MUST be refused for a split — this is the exact reported bypass. - for slot in 0u8..=equip_slot::END { - assert!( - valid_dest_slot(slot), - "slot {slot} should still be in the general 0..=38 range" - ); - assert!( - !valid_split_dest_slot(slot), - "slot {slot} is in the equipment region and must be refused as a split destination" - ); + fn valid_split_dest_slot_accepts_only_storage() { + for slot in 0..=22 { + assert!(valid_dest_slot(slot)); + assert!(!valid_split_dest_slot(slot)); } - // Bag-equip, backpack, bank, and bag-content slots (non-equipment) stay valid split destinations. - for slot in [19u8, 22, 23, 38, 39, 62, 120, 191] { - assert!( - valid_split_dest_slot(slot), - "slot {slot} is outside the equipment region and should remain a valid split destination" - ); + for slot in [23, 38, 39, 62, 120, 191] { + assert!(valid_split_dest_slot(slot)); + } + for slot in [63, 119, 192, 255] { + assert!(!valid_split_dest_slot(slot)); } - // The already-invalid ranges stay invalid. - assert!(!valid_split_dest_slot(63)); - assert!(!valid_split_dest_slot(119)); - assert!(!valid_split_dest_slot(192)); } /// BAG-CONTENT SLOT DECOMPOSITION: a flat bag-content slot (120..=191) decomposes into diff --git a/module/src/items/ops.rs b/module/src/items/ops.rs index ea1288492..19fca8f22 100644 --- a/module/src/items/ops.rs +++ b/module/src/items/ops.rs @@ -457,6 +457,16 @@ pub(crate) fn remove_items( .collect(); let original_counts: Vec = stacks.iter().map(|item| item.stack_count).collect(); super::exchange::consume_item_stacks(&mut stacks, item_entry, count).map_err(String::from)?; + if stacks + .iter() + .zip(&original_counts) + .any(|(item, original_count)| { + item.stack_count != *original_count + && crate::trade::item_is_offered(ctx, owner_guid, item.guid) + }) + { + return Err(super::refused(ItemRefusal::NotRightNow)); + } for (inst, original_count) in stacks.into_iter().zip(original_counts) { if inst.stack_count == original_count { continue; @@ -493,6 +503,7 @@ pub(crate) fn restore_carried_items( for item in items { instances.insert(item); } + crate::trade::refresh_offer(ctx, owner_guid); } /// "Recently Bandaged" debuff spell id (vanilla 11196) — already seeded (a 60s `A_FLAG` marker aura, no @@ -589,6 +600,9 @@ pub(crate) fn apply_item_use( let instances = ctx.db.game_item_instance(); let mut inst = item_in_slot(ctx, player_guid, slot) .ok_or_else(|| refuse(ItemRefusal::ItemNotFound, format!("no item in slot {slot}")))?; + if crate::trade::item_is_offered(ctx, player_guid, inst.guid) { + return Err(ItemRefusal::NotRightNow); + } // A banked item must be taken out before it can be used — the bank is not a second action bar. if !is_carried_slot(inst.slot) { return Err(refuse( diff --git a/module/src/professions.rs b/module/src/professions.rs index 48ece5c03..86c301871 100644 --- a/module/src/professions.rs +++ b/module/src/professions.rs @@ -336,6 +336,11 @@ pub(crate) fn apply_disenchant(ctx: &ReducerContext, guid: u64, slot: u8) -> Res } let inst = crate::items::item_in_slot(ctx, guid, slot) .ok_or_else(|| format!("no item in slot {slot}"))?; + if crate::trade::item_is_offered(ctx, guid, inst.guid) { + return Err(crate::items::refused( + lyracore_shared::item::ItemRefusal::NotRightNow, + )); + } require_gear(ctx, inst.entry)?; // weapon/armor only — never destroy a trade-good stack for 1 dust // AUTO-LEARN Enchanting on the first disenchant (idempotent), giving the skill-up hook a row to climb. crate::skill::learn_profession( diff --git a/module/src/quest.rs b/module/src/quest.rs index 6082ce4f0..9a89928f5 100644 --- a/module/src/quest.rs +++ b/module/src/quest.rs @@ -1189,6 +1189,7 @@ pub(crate) fn request_turn_in_quest( total_exp: xp, created_at: ctx.timestamp, is_kill: false, + rested_bonus: 0, }); crate::xp::grant_xp(ctx, &mut player, xp); } diff --git a/module/src/spell/cast/resolve.rs b/module/src/spell/cast/resolve.rs index 2f228be4b..4a0f6ef9b 100644 --- a/module/src/spell/cast/resolve.rs +++ b/module/src/spell/cast/resolve.rs @@ -126,6 +126,16 @@ pub(crate) fn resolve_cast_at_typed( check_distract_destination(ctx, &caster, dest, hdr.range_yd)?; } + // A scheduled completion retains Refusals, so check and consume the complete reagent + // batch before charging power or changing caster state. Keep identities for product rollback. + let creates_items = effects.iter().any(|effect| effect.kind == E_CREATE_ITEM); + let inventory_before_craft = + creates_items.then(|| crate::items::carried_items(ctx, caster_guid)); + if creates_items { + crate::items::exchange_items(ctx, caster_guid, &recipe_reagents(ctx, spell_id), &[]) + .map_err(String::from)?; + } + // Charge + flush BEFORE the effects (a self-heal effect re-reads the caster row, so it sees the // deducted power; a cost-0 cast writes nothing, preserving the Battle Shout baseline). Deduct ONLY for // players — a creature has no pool to drain (and its power stays 0 for the relayed peer view). @@ -167,20 +177,6 @@ pub(crate) fn resolve_cast_at_typed( break_stealth(ctx, caster_guid); } - // Keep the original identities so a failed product grant can restore reagents even when the - // GUID Range is exhausted. Skills are awarded only after every product grant succeeds. - let creates_items = effects.iter().any(|effect| effect.kind == E_CREATE_ITEM); - let reagents = if creates_items { - recipe_reagents(ctx, spell_id) - } else { - Vec::new() - }; - let inventory_before_craft = - creates_items.then(|| crate::items::carried_items(ctx, caster_guid)); - for (item, count) in reagents { - crate::items::remove_items(ctx, caster_guid, item, count)?; - } - // The effect loop + the ONE cast visual — shared verbatim with the Triggered Cast (`cast_triggered`), // which runs this and nothing else. run_spell_effects( diff --git a/module/src/spell/control.rs b/module/src/spell/control.rs index 6e0762cb4..b1021d68e 100644 --- a/module/src/spell/control.rs +++ b/module/src/spell/control.rs @@ -152,14 +152,13 @@ pub fn buff_status( } fn is_channel_aura(ctx: &ReducerContext, aura: &Aura) -> bool { - aura.eff_kind == A_PERIODIC_TRIGGER - || (aura.eff_kind == A_PERIODIC_ENERGIZE - && ctx - .db - .game_spell() - .spell_id() - .find(aura.spell_id) - .is_some_and(|spell| spell.cast_flags & SPELL_ATTR_CHANNELED != 0)) + let flags = ctx + .db + .game_spell() + .spell_id() + .find(aura.spell_id) + .map_or(0, |spell| spell.cast_flags); + lyracore_shared::spell::is_channel_aura(aura.eff_kind, flags) } /// True while a cast bar or channel occupies the unit's non-melee spell slot. @@ -170,7 +169,12 @@ pub(crate) fn is_non_melee_spell_casting(ctx: &ReducerContext, unit_guid: u64) - .filter(&unit_guid) .next() .is_some() - || auras_on(ctx, unit_guid).any(|aura| is_channel_aura(ctx, &aura)) + || ctx + .db + .game_aura() + .by_caster() + .filter(&unit_guid) + .any(|aura| is_channel_aura(ctx, &aura)) } /// Is `unit_guid` STUNNED — carrying an active `A_CONTROL(M_STUN)` aura? A stunned unit can neither ACT @@ -565,23 +569,11 @@ pub(crate) fn expire_cast_attempt( interrupt_cast_with_outcome(ctx, caster_guid, CastFinish::Expired) } -/// BREAK a channel on `caster_guid` — the ONE convergent break path for the four channel-interrupt triggers -/// (the caster MOVES / starts a NEW cast / is CC'd / its channel target dies). Deletes the caster's -/// `A_PERIODIC_TRIGGER` channel aura(s) so `tick_auras` stops firing the per-tick missile, AND cancels any -/// in-progress timed cast via `interrupt_cast` (a channel and a cast-bar are mutually exclusive in practice, -/// but covering both keeps every call site a single tear-down). Collect-then-delete (never mutate while -/// iterating), modeled on `break_stealth`. Returns whether anything was broken. NO-OP for a caster with no -/// channel/cast (the common path) — every call site can fire it unconditionally. Generic over the kind, -/// never a spell id. [entity] +/// Remove channel auras from every affected unit and cancel the caster's pending cast. pub(crate) fn break_channel(ctx: &ReducerContext, caster_guid: u64) -> bool { let auras = ctx.db.game_aura(); - // A channel is a SELF-aura → it sits on the caster's own `target_guid`; scope by `by_target` then - // filter the channel kind (the same idiom as break_stealth's A_STEALTH scope). A_PERIODIC_TRIGGER is - // ALWAYS a channel tick (Arcane Missiles). A_PERIODIC_ENERGIZE is channel-borne ONLY when its parent - // spell carries the CHANNELED flag (Evocation) — a NON-channeled periodic energize (Bloodrage's rage - // trickle) must survive, so it is gated on a header join to `cast_flags & SPELL_ATTR_CHANNELED`. let ids: Vec = auras - .by_target() + .by_caster() .filter(&caster_guid) .filter(|aura| is_channel_aura(ctx, aura)) .map(|a| a.id) diff --git a/module/src/spell/tables.rs b/module/src/spell/tables.rs index 00e46d238..62c11db18 100644 --- a/module/src/spell/tables.rs +++ b/module/src/spell/tables.rs @@ -180,6 +180,7 @@ pub struct SpellProcEvent { accessor = game_aura, public, index(accessor = by_target, btree(columns = [target_guid])), + index(accessor = by_caster, btree(columns = [caster_guid])), index(accessor = by_expiry, btree(columns = [expires_at])), // Creature fear movement runs every firing. This exact pair finds its sparse candidates without // reading unrelated buffs, passive effects, periodic effects, or Procs. diff --git a/module/src/spell/taxonomy.rs b/module/src/spell/taxonomy.rs index 45dcdc48c..3f23a8603 100644 --- a/module/src/spell/taxonomy.rs +++ b/module/src/spell/taxonomy.rs @@ -54,7 +54,7 @@ pub(crate) const SPELL_ATTR_REQ_REVENGE: u32 = 0x0040; /// bit (`0x44`), with a by-NAME fallback (Arcane Missiles); it ALSO drives the importer's reclassification of /// the periodic-trigger effect (`PeriodicTriggerSpell`/`E_TRIGGER` with `period_ms>0` + a T_SELF target) into /// `A_PERIODIC_TRIGGER`. The engine reads this bit / the kind, NEVER a spell id. -pub(crate) const SPELL_ATTR_CHANNELED: u32 = 0x0080; +pub(crate) use lyracore_shared::spell::SPELL_ATTR_CHANNELED; /// Backstab: castable ONLY while the caster has a DAGGER (weapon subclass 15) equipped in the MAIN-HAND /// slot (15) — vanilla's melee-weapon-type restriction. Gate: join the caster's `game_item_instance` in /// the main-hand slot -> `game_item_template` -> `subclass == weapon_subclass::DAGGER`; a missing/broken/ diff --git a/module/src/talent.rs b/module/src/talent.rs index 5373eddf3..fcf9b229c 100644 --- a/module/src/talent.rs +++ b/module/src/talent.rs @@ -630,16 +630,7 @@ pub fn apply_learned_talents(ctx: &ReducerContext, guid: u64, owner: Identity, l } } -pub(crate) fn respec_cost_copper(respec_count: u32) -> u32 { - const CAP_COPPER: u32 = 500_000; - let copper = match respec_count { - 0 => 10_000, - 1 => 50_000, - 2 => 100_000, - n => 100_000 + 50_000 * (n - 2), - }; - copper.min(CAP_COPPER) -} +pub(crate) use lyracore_shared::talent::respec_cost_copper; /// Shared validated core of a talent respec (`gw_reset_talents` and the debug reducer both call /// this). Gates @@ -1063,5 +1054,6 @@ mod tests { assert_eq!(respec_cost_copper(10), 500_000); // cap (50g) assert_eq!(respec_cost_copper(11), 500_000); // stays capped assert_eq!(respec_cost_copper(1000), 500_000); + assert_eq!(respec_cost_copper(u32::MAX), 500_000); } } diff --git a/module/src/trade.rs b/module/src/trade.rs index c5524b4c9..3f9ec81c4 100644 --- a/module/src/trade.rs +++ b/module/src/trade.rs @@ -153,6 +153,17 @@ pub(crate) fn session_involving(ctx: &ReducerContext, guid: u64) -> Option bool { + session_involving(ctx, owner_guid).is_some_and(|session| { + ctx.db + .game_trade_slot() + .by_session() + .filter(&session.id) + .any(|slot| slot.owner_guid == owner_guid && slot.item_guid == item_guid) + }) +} + /// Delete a session and its slot rows — the one spelling of teardown, shared by cancel, the /// character sweep, and the Trade Commit. fn remove_session(ctx: &ReducerContext, session: &TradeSession) { @@ -380,6 +391,15 @@ fn reset_accepts(ctx: &ReducerContext, mut session: TradeSession) -> TradeSessio session } +/// Refresh changed or restored inventory. Both Characters must accept the current offer. +pub(crate) fn refresh_offer(ctx: &ReducerContext, owner_guid: u64) { + if let Some(session) = session_involving(ctx, owner_guid).filter(|session| session.open) { + let session = reset_accepts(ctx, session); + let session = touch_session(ctx, session); + push_offer_events(ctx, &session, owner_guid); + } +} + /// `CMSG_SET_TRADE_ITEM` core: place the item in inventory slot `inv_slot` into window /// slot `trade_slot`. The gateway already mapped the client's (bag, slot) addressing onto the /// absolute slot (the item-family convention). No open session is a silent no-op (client races a diff --git a/module/src/xp.rs b/module/src/xp.rs index bafe6c13e..245e5964a 100644 --- a/module/src/xp.rs +++ b/module/src/xp.rs @@ -32,6 +32,9 @@ pub struct XpEvent { /// rows + the kill path are unchanged. [entity] #[default(true)] pub is_kill: bool, + /// The rested share of this award. Older events carry no split information. + #[default(0u32)] + pub rested_bonus: u32, } /// A level-up ("ding"), delivered to the player only → `SMSG_LEVELUP_INFO`. [event] @@ -372,6 +375,7 @@ pub(crate) fn award_xp( total_exp: award, created_at: ctx.timestamp, is_kill: true, + rested_bonus: award - base, }); grant_xp(ctx, &mut p, award); entities.guid().update(p); diff --git a/module/tests/inventory_protocol.rs b/module/tests/inventory_protocol.rs new file mode 100644 index 000000000..6fda03884 --- /dev/null +++ b/module/tests/inventory_protocol.rs @@ -0,0 +1,222 @@ +mod support; + +use lyracore_shared::trade::{decode_offer, event_kind}; +use support::{number, poll_until, Standalone}; + +const ACTOR: &str = r#"{"guid":1,"ownership":null}"#; + +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", &["1"]); + shard.assert_sql("DELETE FROM game_item_instance"); + shard +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn inventory_changes_preserve_accepted_trade_items() { + let shard = fixture("inventory-trade-offer"); + shard.assert_call( + "create_character", + &["1", "\"Other\"", "1", "1", "0", "0", "0", "0", "0", "0"], + ); + let other = + shard.query_rows("SELECT guid FROM game_character WHERE name = 'Other'")[0]["guid"].clone(); + shard.assert_call("debug_spawn_player_entity", &[&other]); + shard.assert_sql("UPDATE game_world_entity SET x = 100, y = 100, z = 100 WHERE guid = 1"); + shard.assert_sql(&format!( + "UPDATE game_world_entity SET x = 100, y = 100, z = 100, money = 100 WHERE guid = {other}" + )); + shard.assert_sql("UPDATE game_world_entity SET money = 0 WHERE guid = 1"); + shard.assert_sql("UPDATE game_world_entity SET health = 1 WHERE guid = 1"); + shard.assert_call("debug_grant_item", &["1", "5090052", "15"]); + shard.assert_call("debug_split_item", &["1", "23", "5", "24"]); + shard.assert_call("debug_grant_item", &["1", "5090050", "1"]); + shard.assert_call("gw_move_item", &[ACTOR, "25", "26"]); + shard.assert_call("debug_spawn_at_feet", &["1", "51003", "0"]); + let vendor = shard.query_rows("SELECT guid FROM game_world_entity WHERE entry = 51003")[0] + ["guid"] + .clone(); + shard.assert_sql(&format!( + "UPDATE game_world_entity SET npc_flags = 4 WHERE guid = {vendor}" + )); + let buyer = format!(r#"{{"guid":{other},"ownership":null}}"#); + shard.assert_call("gw_initiate_trade", &[ACTOR, &other]); + shard.assert_call("gw_begin_trade", &[&buyer]); + shard.assert_call("gw_set_trade_item", &[ACTOR, "0", "23"]); + shard.assert_call("gw_set_trade_item", &[ACTOR, "1", "26"]); + shard.assert_call("gw_set_trade_gold", &[&buyer, "100"]); + shard.assert_call("gw_accept_trade", &[&buyer]); + let items = shard.query_rows("SELECT * FROM game_item_instance"); + let session = shard.query_rows("SELECT * FROM game_trade_session"); + assert_eq!(session[0]["target_accepted"], "true"); + shard.assert_sql("INSERT INTO game_spell (spell_id,name,power_type,cost,cast_time_ms,gcd_ms,cooldown_ms,range_yd,duration_ms,school_mask,dispel_type,mechanic,max_stacks,aura_interrupt,attributes,spell_level,max_level,is_negative,cast_flags,stances,family_name,family_flags,proc_flags,proc_chance,proc_charges) VALUES (5090171,'Fixture craft',0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,false,0,0,0,0,0,0,0)"); + shard.assert_sql("INSERT INTO game_spell_effect (id,spell_id,effect_index,kind,base_points,die_sides,per_level,period_ms,target,radius_yd,chain_targets,trigger_spell,effect_mechanic,p0,p0_kind,p1,script_id,enters_combat) VALUES (20360684,5090171,0,7,1,0,0.0,0,0,0.0,0,0,0,5090050,8,0,0,false)"); + shard.assert_sql("INSERT INTO game_spell_reagent (id,spell_id,item_entry,count) VALUES (40721368,5090171,5090052,1)"); + for (verb, args) in [ + ("gw_split_item", vec![ACTOR, "23", "25", "9"]), + ("gw_destroy_item", vec![ACTOR, "23", "9"]), + ("gw_destroy_item", vec![ACTOR, "23", "0"]), + ("gw_move_item", vec![ACTOR, "23", "25"]), + ("gw_move_item", vec![ACTOR, "24", "23"]), + ("gw_use_item", vec![ACTOR, "23"]), + ("gw_sell_item", vec![ACTOR, &vendor, "23"]), + ("gw_disenchant", vec![ACTOR, "26"]), + ("debug_cast_at", vec!["1", "5090171", "1"]), + ] { + let result = shard.call(verb, &args); + assert!(!result.status.success(), "{verb} changed an offered item"); + let output = format!( + "{}{}", + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&result.stderr) + ); + assert!(output.contains("item:not_right_now"), "{output}"); + assert_eq!(shard.query_rows("SELECT * FROM game_item_instance"), items); + assert_eq!( + shard.query_rows("SELECT * FROM game_trade_session"), + session + ); + } + shard.assert_call("debug_grant_item", &["1", "5090054", "1"]); + shard.assert_sql("UPDATE game_spell SET cast_time_ms = 1000, cost = 10, power_type = 1 WHERE spell_id = 5090171"); + shard.assert_sql("UPDATE game_spell_reagent SET item_entry = 5090054 WHERE id = 40721368"); + shard.assert_sql("INSERT INTO game_spell_reagent (id,spell_id,item_entry,count) VALUES (40721369,5090171,5090052,1)"); + // Keep regeneration separate from the scheduled craft's power accounting. + shard.assert_sql("DELETE FROM game_creature_move_schedule"); + shard.assert_sql("UPDATE game_world_entity SET power = 100 WHERE guid = 1"); + let before_craft = shard.query_rows("SELECT * FROM game_item_instance"); + shard.assert_call("debug_begin_cast", &["1", "5090171", "1"]); + assert_eq!( + shard + .query_rows("SELECT scheduled_id FROM game_pending_cast WHERE caster_guid = 1") + .len(), + 1 + ); + assert!(poll_until(std::time::Duration::from_secs(5), || shard + .query_rows("SELECT scheduled_id FROM game_pending_cast WHERE caster_guid = 1") + .is_empty())); + assert_eq!( + shard.query_rows("SELECT * FROM game_item_instance"), + before_craft + ); + assert_eq!( + shard.query_rows("SELECT power FROM game_world_entity WHERE guid = 1")[0]["power"], + "100" + ); + assert_eq!( + shard.query_rows("SELECT * FROM game_trade_session"), + session + ); + shard.assert_sql("DELETE FROM game_spell_reagent WHERE id = 40721369"); + shard.assert_sql("UPDATE game_spell_effect SET p0 = 5090052 WHERE id = 20360684"); + shard.assert_call("debug_begin_cast", &["1", "5090171", "1"]); + assert_eq!( + shard + .query_rows("SELECT scheduled_id FROM game_pending_cast WHERE caster_guid = 1") + .len(), + 1 + ); + assert!(poll_until(std::time::Duration::from_secs(5), || shard + .query_rows("SELECT scheduled_id FROM game_pending_cast WHERE caster_guid = 1") + .is_empty())); + assert_eq!( + shard.query_rows("SELECT power FROM game_world_entity WHERE guid = 1")[0]["power"], + "90" + ); + assert_eq!( + shard.query_rows( + "SELECT stack_count FROM game_item_instance WHERE owner_guid = 1 AND slot = 23" + )[0]["stack_count"], + "11" + ); + let changed = shard.query_rows("SELECT * FROM game_trade_session"); + assert_eq!(changed[0]["target_accepted"], "false"); + assert_eq!(changed[0]["initiator_accepted"], "false"); + shard.assert_call("debug_grant_item", &["1", "5090054", "1"]); + shard.assert_call("gw_accept_trade", &[&buyer]); + shard.assert_sql("INSERT INTO game_spell_effect (id,spell_id,effect_index,kind,base_points,die_sides,per_level,period_ms,target,radius_yd,chain_targets,trigger_spell,effect_mechanic,p0,p0_kind,p1,script_id,enters_combat) VALUES (20360685,5090171,1,7,1,0,0.0,0,0,0.0,0,0,0,5090999,8,0,0,false)"); + let before_failed_product = shard.query_rows("SELECT * FROM game_item_instance"); + // Retain offer events while the test inspects their final quantities. + shard.assert_sql("DELETE FROM game_event_reaper_schedule"); + shard.assert_sql("DELETE FROM game_trade_event"); + shard.assert_call("debug_begin_cast", &["1", "5090171", "1"]); + assert!(poll_until(std::time::Duration::from_secs(5), || shard + .query_rows("SELECT scheduled_id FROM game_pending_cast WHERE caster_guid = 1") + .is_empty())); + assert_eq!( + shard.query_rows("SELECT * FROM game_item_instance"), + before_failed_product + ); + let changed = shard.query_rows("SELECT * FROM game_trade_session"); + assert_eq!(changed[0]["target_accepted"], "false"); + assert_eq!(changed[0]["initiator_accepted"], "false"); + let events = shard.query_rows("SELECT * FROM game_trade_event"); + for kind in [event_kind::OFFER_SELF, event_kind::OFFER_PARTNER] { + let last_offer = events + .iter() + .filter(|event| number::(event, "kind") == kind) + .max_by_key(|event| number::(event, "id")) + .unwrap(); + let (_, offer) = decode_offer(&last_offer["payload"]).unwrap(); + assert_eq!( + offer + .iter() + .find(|slot| slot.trade_slot == 0) + .unwrap() + .stack_count, + 11 + ); + } + shard.assert_call("gw_split_item", &[ACTOR, "24", "27", "1"]); + shard.assert_call("gw_accept_trade", &[ACTOR]); + assert_eq!( + shard.query_rows("SELECT * FROM game_trade_session").len(), + 1 + ); + shard.assert_call("gw_accept_trade", &[&buyer]); + let received = shard.query_rows(&format!( + "SELECT stack_count FROM game_item_instance WHERE owner_guid = {other} AND entry = 5090052" + )); + assert_eq!(received.len(), 1); + assert_eq!(received[0]["stack_count"], "11"); + assert_eq!(shard.query_rows(&format!("SELECT stack_count FROM game_item_instance WHERE owner_guid = {other} AND entry = 5090050"))[0]["stack_count"], "1"); + assert_eq!( + shard.query_rows("SELECT money FROM game_world_entity WHERE guid = 1")[0]["money"], + "100" + ); + assert_eq!( + shard.query_rows(&format!( + "SELECT money FROM game_world_entity WHERE guid = {other}" + ))[0]["money"], + "0" + ); + assert!(shard + .query_rows("SELECT * FROM game_trade_session") + .is_empty()); +} + +#[test] +#[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] +fn two_handed_weapons_and_offhand_items_cannot_be_equipped_together() { + let shard = fixture("inventory-two-hand"); + shard.assert_sql("UPDATE game_item_template SET class = 2, subclass = 8, inventory_type = 17 WHERE entry = 5090050"); + shard.assert_sql("UPDATE game_item_template SET class = 4, subclass = 6, inventory_type = 14 WHERE entry = 5090054"); + shard.assert_call("debug_grant_item", &["1", "5090050", "1"]); + shard.assert_call("debug_grant_item", &["1", "5090054", "1"]); + shard.assert_call("gw_move_item", &[ACTOR, "24", "16"]); + let before = shard.query_rows("SELECT * FROM game_item_instance"); + assert!(!shard.call("gw_equip_item", &[ACTOR, "23"]).status.success()); + assert_eq!(shard.query_rows("SELECT * FROM game_item_instance"), before); + shard.assert_call("gw_move_item", &[ACTOR, "16", "24"]); + shard.assert_call("gw_equip_item", &[ACTOR, "23"]); + let before = shard.query_rows("SELECT * FROM game_item_instance"); + assert!(!shard.call("gw_equip_item", &[ACTOR, "24"]).status.success()); + assert_eq!(shard.query_rows("SELECT * FROM game_item_instance"), before); + shard.assert_call("gw_move_item", &[ACTOR, "15", "23"]); + shard.assert_call("gw_equip_item", &[ACTOR, "24"]); +} diff --git a/module/tests/mount.rs b/module/tests/mount.rs index 2ab15f73d..65e4426de 100644 --- a/module/tests/mount.rs +++ b/module/tests/mount.rs @@ -61,6 +61,8 @@ fn assert_projection(shard: &Standalone, display: u32, speed_bp: u32) { #[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"); + // Keep scheduled rage decay out of the refusal's before-and-after comparison. + shard.assert_sql("DELETE FROM game_creature_move_schedule"); let items = reins(&shard); assert_eq!(items.len(), 1); let vitals = shard.query_rows( diff --git a/module/tests/random_property_stats.rs b/module/tests/random_property_stats.rs index cfbf5967a..406ee0137 100644 --- a/module/tests/random_property_stats.rs +++ b/module/tests/random_property_stats.rs @@ -246,6 +246,8 @@ fn defenses_and_each_magic_resistance_reach_combat_callers() { #[ignore = "requires the SpacetimeDB 2.7.1 CLI and Wasm toolchain"] fn spell_power_selects_school_and_healing_power_only_affects_heals() { let shard = fixture("property-stat-spells", 8); + // Measure the heal without scheduled regeneration changing the same health pool. + shard.assert_sql("DELETE FROM game_creature_move_schedule"); enchantments( &shard, &[(16, 20, 4), (16, 30, 16), (17, 40, 2), (17, 60, 4)],